Intelligence Pipeline — Shopify Store Intelligence
Pipeline d'intelligence Shopify pour BoostEcom : prend une URL en entrée, retourne tout ce qu'on sait/déduit du store, alimente le chat multi-agents et une page discovery filtrable. Spec finalisée 2026-05-12…
Pipeline d'intelligence Shopify pour BoostEcom : prend une URL en entrée, retourne tout ce qu'on sait/déduit du store, alimente le chat multi-agents et une page discovery filtrable. Spec finalisée 2026-05-12. Implémentation en trois phases.
Mise à jour 2026-05-22 (branche
claude/elegant-sagan-TRd77) : Phase 1.5 livrée, voir §20 "Phase 1.5, Mega-PR" en bas de doc pour le diff complet vs la spec originelle.Branche de travail historique :
claude/integrate-shared-algorithm-LiTmh.
1. North star
Un merchant ou un agent BoostEcom paste une URL Shopify. En ≤ 15 secondes, on retourne :
- Catalogue complet + collections + structure de pricing
- Apps installées (Klaviyo, Judge.me, Loox, Yotpo, Recharge, …) + thème + customisations
- Stack pixels (Meta, TikTok, Google, Snap, Klaviyo Onsite) + setup server-side
- Activité publicitaire Meta (ad library) + signaux TikTok visibles
- Volume reviews + vélocité (Judge.me / Loox / Yotpo / Trustpilot)
- Estimation sales velocity + AOV + chiffre d'affaires (avec intervalle de confiance explicite)
- Cadence email (si dispo) + popup capture + post-purchase apps
- Trajectoire de croissance (7 / 30 / 90 j)
- Anomalies détectées (changement de thème, restock massif, burst d'ads, etc.)
Cette intelligence nourrit :
- Le chat multi-agents — chaque agent reçoit la slice qui le concerne et propose des actions concrètes
- Une page discovery filtrable type outil de référence, mais avec recherche en langage naturel et similarity search vectorielle
- Une boucle de calibration OAuth : chaque merchant connecté en Shopify Admin API devient un point de vérité terrain qui ré-entraîne les modèles d'inférence
2. Peut-on réellement battre l'outil de référence + SimilarWeb ?
Cette question doit être tranchée honnêtement avant de construire quoi que ce soit. Sinon on construit un produit qui prétend battre un benchmark qu'il n'atteindra pas.
Dimensions où on perdra (à accepter)
| Dimension | Pourquoi on perd | Position défendable |
|---|---|---|
| Panel trafic global | SimilarWeb a un panel multi-millions d'utilisateurs + deals FAI. Coût d'entrée à 8-9 chiffres. | On ne reconstruit pas. On licence SimilarWeb en wholesale (~$0.10-1/domain selon volume) ou on s'en passe et on se différencie ailleurs. |
| Couverture stores indexés | L'outil de référence a des millions de stores en pré-indexage continu depuis des années. | On démarre en on-demand (un user submit, on fetch live). On pré-indexe progressivement uniquement les stores fréquemment demandés. |
| Historique | L'outil de référence a 3+ ans de delta sur des millions de marques. | On démarre à T=0. Notre historique se construit avec nos users. C'est OK pour un product de niche. |
| Email cadence corpus | L'outil de référence a une flotte honeypot warmée depuis des années. | Voir §17 (risques) : honeypot massif = €20M / 4% de revenu global de fines RGPD. On n'y va pas. On limite à ce qu'on peut obtenir sans honeypot (popups + landing) ou via deals partenaires (Klaviyo benchmark API). |
Dimensions où on écrase structurellement (le vrai moat)
| Dimension | Pourquoi on gagne | Reproductibilité par eux |
|---|---|---|
| Précision-par-store-connecté | OAuth Shopify Admin = vraie data. Pas d'estimation. Revenu réel, AOV réel, CVR réel, source réelle de chaque commande, LTV, cohortes. | Impossible. L'outil de référence n'a pas d'OAuth vers le store Shopify du marchand — ils ont bien un OAuth 2.1 vers leur propre workspace pour leur MCP (cf. mcp-oauth.md §outil de référence), ce qui est une autre chose : on s'y authentifie pour lire leur base, pas pour lire son store. SimilarWeb non plus. C'est notre moat irréplicable. |
| Boucle de calibration | Chaque merchant connecté → on compare prédiction vs vraie data → on ré-entraîne. À 100 stores on bat SimilarWeb sur la précision Shopify spécifique. À 1000 stores l'écart est définitif. | Impossible sans pivoter leur business model. |
| Capacité d'action | Marco peut mutate le theme, Maya peut lancer une campagne, Otis peut shipper un flow Klaviyo. Ils montrent des chiffres, on agit. | Hors scope pour eux. C'est une question de positionnement, pas de tech. |
| Multi-agents experts | 6 spécialistes (Atlas + Maya/Marco/Otis/Faye/Sam). Chacun lit la couche d'intelligence avec sa lentille métier. | Hors scope pour eux. C'est notre architecture native. |
| Recherche en langage naturel | "Stores skincare français qui scalent sur TikTok avec Klaviyo et <50 produits" → parsed en filtres structurés via LLM. | Reproductible mais ne fait pas partie de leur DNA. |
| Similarity search | Embedding par store (positionnement sémantique). Lookalikes instantanés. | Reproductible mais inexistant chez eux à ce jour. |
| Cadence de refresh | Pour notre set actif on poll à 1-3h. L'outil de référence annonce 24h, et avec 1-2 semaines de lag sur le trafic (page publique de l'outil). | On gagne sur les signaux non-trafic. |
Reformulation honnête du positionnement
L'outil de référence est un outil de discovery & spy pour agences/dropshippers. Leur valeur = couverture + filtres.
SimilarWeb est une traffic intelligence généraliste. Leur valeur = panel + historique multi-vertical.
BoostEcom Intelligence est une operator intelligence pour merchants Shopify connectés. Notre valeur = précision sur ton store + concurrents pertinents + capacité d'action multi-agents.
On ne joue pas le même jeu. Et c'est exactement ce qui rend le combat gagnable.
3. Synthèse de la conversation source (avec critique)
La conversation Claude partagée par l'utilisateur (référence : share/1ca8b595-9c30-47a1-a185-80573133458a) pose le cadre. Ce qu'on garde, ce qu'on rejette :
Ce qu'on garde
- L'outil de référence = wrapper SimilarWeb + orchestrateur de scraping : démystification correcte. Pas de magie technique, c'est de l'agrégation + UX.
- 40 workers fan-out + façade unifiée : bon cadrage. Pas "1 algo monolithique".
- Boucle de calibration OAuth comme moat : central. C'est notre arme.
- LLM-as-extractor pour résilience aux changements de thème : bon usage 2026.
- Vision models sur creas pour clustering ad creatives : bon usage 2026.
- Distinction consommer MCP / exposer MCP: on consomme SimilarWeb MCP + MCP de tendances (si abonnement viable) en backend. On n'expose pas notre MCP en v1.
Ce qu'on rejette ou nuance
| Point de la conversation | Notre position | Raison |
|---|---|---|
| "Delta order ID via reviews datées = mesure précise non-extrapolée" | Largement obsolète | Shopify a fermé l'exposition publique des order IDs sur les checkouts modernes. La technique marche sur <20% des stores en 2026. À ne pas mettre en pierre angulaire. |
"inventory_quantity exposé publiquement = signal stock fiable" | Minoritaire | Sur Dawn par défaut oui, sur thèmes premium / customs / Hydrogen non. Disponibilité réelle ~30-40% des stores. |
| "Stack 4 stores Phase 1 : Postgres + ClickHouse + Qdrant + S3" | Overkill | Neon Postgres + pgvector + Vercel Blob suffisent. Benchmarks 2026 montrent pgvector + pgvectorscale à 471 QPS vs Qdrant 41 QPS sur 50M vecteurs à 99% recall — 11.4× plus rapide (tigerdata). ClickHouse on l'ajoute quand on aura le volume time-series, pas avant. |
| "Flotte email honeypot pour capter sequences" | À ne pas faire en v1 | Risque RGPD majeur. €20M / 4% de revenu global de fines. France a déjà fined KASPR €240K pour contact scraping (Apollo). On limite à popup + landing capture. |
| "Phase 1 en 4-6 semaines" | 3-4 sem pour scope minimal, 8-12 sem pour Phase 2 | Sous-estimé pour scope étendu. On découpe plus serré. |
| "Coût $0.10-0.30 par lookup à froid" | $0.50-1.50 réaliste | Ignore proxies résidentiels (Browserbase $10-12/GB), browser time ($0.10-0.12/h), Cloudflare bypass sur 99.2% des stores Shopify (scrapfly). |
| Routing "Faye = Intelligence" | Confirmé dans le code | identity-registry.ts définit explicitement Faye = Intelligence AI. La CLAUDE.md (Faye=Finance) est en retard, à corriger. |
4. État du repo existant (audit)
L'audit complet (mené par l'agent Explore sur /home/user/boostecom.app/src/) montre que ~70% de l'infrastructure existe déjà et est production-ready. À réutiliser plutôt qu'à reconstruire :
| Brique existante | Localisation | Rôle dans le pipeline |
|---|---|---|
| Scan orchestrator (Browserbase + Playwright, phases, SSE, rate-limit, SSRF) | src/features/tracking/scan/orchestrator.ts v1.8.0 | Base du crawler. On ajoute des phases. |
Probes Shopify (catalog /products.json, theme, framework) | src/services/algorithms/intelligence/probes/ | Fondations catalog crawl. On étend. |
| Shopify SDK + token refresh OAuth | src/features/shopify/sdk/, src/features/connectors/ | OAuth Admin API pour calibration ground truth. |
MCP Shopify (18 tools, 0 resource — compte derive par la claim mcp.tools de scripts/check-doc-claims.mjs) | src/app/api/mcp/[storeId]/register-tools.ts | Côté exécution (Marco). Pas touché. |
| Job queue QStash + dispatcher | src/services/jobs/dispatcher.ts, services/jobs/types.ts | Workers asynchrones. On ajoute des job types. |
| Browser/scraper utilities | src/lib/browser/, src/lib/scraper/store-meta.ts | Headless + parsing. Réutilisable. |
| SSE public scan route | src/app/api/tracking/scan/route.ts | Pattern pour /api/intelligence/scan. |
| Agents Atlas + 5 spécialistes (avec mascots, bios, tool catalog) | src/features/ai/agents/identity-registry.ts | Routing intelligence vers Faye + slices vers les autres. |
Prisma : Store, StoreFact, AlgoTrace, StoreReport, Task | prisma/schema.prisma | Stockage. On étend avec type="INTELLIGENCE" sur StoreReport ou nouveau modèle CompetitorScan. |
Memory layer (OrgFact/StoreFact/UserFact + RAG Upstash Vector) | spec docs/architecture/memory-layer.md | Persistence facts. On pousse les facts intelligence dedans. |
| Cron infra | src/app/api/cron/*, vercel.json | Refresh asynchrone. |
Ce qui manque (le scope réel à construire)
| Manque | Action |
|---|---|
| Probes apps / pixels / reviews / Meta Ad Library | Créer dans src/services/algorithms/intelligence/probes/ |
| Modèle de données pour snapshots intelligence | Étendre StoreReport avec type="INTELLIGENCE" ou nouveau CompetitorScan |
| Sales velocity inference engine | Créer src/services/algorithms/intelligence/inference/ |
| Calibration OAuth ground truth | Service dans src/services/algorithms/intelligence/calibration/ |
| Tools agents pour appeler l'intelligence | Ajouter à src/features/ai/agents/agents/specialists.ts |
| Dashboard discovery (UI filtres + NL search) | Créer src/app/(dashboard)/[orgSlug]/intelligence/ |
| Vector index pour similarity search | pgvector sur Neon, namespace store-intelligence |
Implication architecturale : on ne crée pas un nouveau pipeline parallèle. On étend l'orchestrator de tracking/scan avec 4 nouvelles phases, on étend le schema Prisma avec un champ ou un modèle, et on étend chaque agent avec 1-2 tools ciblés. Le scope réel est ~30% de code nouveau.
5. Architecture cible
5.1 Vue d'ensemble
[User submit URL] [Agent ask via chat]
↓ ↓
└──────────────┬─────────────────────────────┘
↓
[/api/intelligence/scan]
(SSE, rate-limit, SSRF)
↓
[Cache check Redis]
↓ HIT → return (<500ms)
↓ MISS
[Validation Shopify fingerprint] (200ms)
↓
[Parallel fan-out, budget 15s max]
├─ catalog_crawler (3-8s)
├─ sitemap_parser (1-3s)
├─ storefront_renderer + LLM extract (2-3s)
├─ apps_detector (1-2s)
├─ pixels_detector (1-2s)
├─ theme_detector (1s)
├─ reviews_aggregator (2-3s)
├─ meta_ad_library_fetcher (2-3s)
└─ traffic_proxy (SimilarWeb wholesale, optionnel)
↓
[Inference engine] (1-2s)
├─ sales_velocity_estimator
├─ revenue_estimator (avec CI)
├─ growth_trajectory
└─ anomaly_detector
↓
[Persist to Prisma + pgvector index]
↓
[Stream response → user / agent]
↓
[Background jobs (QStash)]
├─ periodic_recrawl (1-6h selon priorité)
├─ ad_library_refresh
└─ calibration_compare (si store OAuth-connected)
Latence cible :
- Cold path : 12-20 secondes
- Hot path (cache) : <500 ms
- Background refresh : asynchrone, non bloquant
5.2 Workers par domaine
Domaine A — Catalog & inventory
| Worker | Méthode validée | Source |
|---|---|---|
shop_resolver | Fingerprint Shopify : cdn.shopify.com, Shopify.theme, __st ; normalise myshopify.com vs custom domain | ecomm.design |
catalog_full_crawler | /products.json?limit=250&page=N, paginé. Délais 1-2s entre pages, 5s+ entre stores. Public, pas d'auth. | DEV/agenthustler |
collections_crawler | /collections.json + /collections/{handle}/products.json. L'ordre = signal best-seller. | Validation au Phase 2 |
sitemap_watcher | sitemap.xml, sitemap_products_*.xml. Dates published_at / updated_at. | Standard Shopify |
catalog_delta_watcher | Repoll 1-6h selon priorité, émet diffs (new product, removed, price change, available flip). | Phase 2 |
variant_inventory_sniffer | /products/{handle}.js quand inventory_quantity exposé. ~30-40% des stores seulement en pratique. | Mesure terrain à valider |
Domaine B — Storefront stack
| Worker | Méthode |
|---|---|
homepage_renderer | Browserbase headless, screenshot + DOM. Coût ~$0.10-0.12/h browser time. |
theme_detector | Lecture Shopify.theme JS object + fingerprint Dawn/Symmetry/Impulse/Prestige/Showcase/Empire/Motion/Pipeline. |
app_detector | Patterns DOM/scripts pour ~200 apps mainstream (Klaviyo, Judge.me, Loox, Yotpo, Postscript, Recharge, Bold, Vitals, ReConvert, Zipify, …). |
pixel_detector | Meta fbq, TikTok ttq, Google gtag, Snap snaptr, Pinterest, Klaviyo Onsite. Server-side via Addingwell. Déjà partiellement implémenté dans tracking/scan. |
tech_stack_detector | CDN, currency converter, A/B testing, headless setup, custom checkout. |
pdp_renderer | Render Product Detail Page : variantes visibles, reviews intégrées, cross-sells. |
Domaine C — Reviews & social proof
| Worker | Méthode |
|---|---|
judge_me_scraper | Public API + Apify fallback. Cap rate ~1 req/s. |
loox_scraper | API publique + Apify. |
yotpo_scraper | Public review widget API. |
stamped_scraper, okendo_scraper, trustpilot_scraper | Patterns équivalents. |
review_velocity_aggregator | Combine sources → reviews/jour × inverse-rate niche (5-15%) → orders/jour estimés. Signal critique pour sales velocity. |
social_handle_extractor | Footer + structured data → IG/TikTok/YT handles. |
Domaine D — Ad intelligence
| Worker | Méthode | Limites |
|---|---|---|
meta_ad_library_fetcher | Implémenté — deux adaptateurs chaînés (ad-library/index.ts). meta-graph = l'API officielle ads_archive, gratuite, synchrone, avec les disclosures DSA (reach, ciblage, payeur) ; apify = l'acteur navigateur payant en repli. Gratuit d'abord, payant seulement si le gratuit revient vide. Clé : META_AD_LIBRARY_TOKEN. | Rate limit 200 calls/heure (Meta). ad_type=ALL ne rend les pubs COMMERCIALES que pour l'UE/UK — ailleurs l'endpoint renvoie du politique uniquement, d'où le repli Apify pour les marchés hors UE. |
tiktok_creative_center_fetcher | Scrape Creative Center + CCL (Commercial Content Library) pour EU/EEA/UK/Switzerland. | Pas d'API commerciale : Commercial Content API réservée chercheurs/journalistes accrédités. Apify scrapers ou crawl direct. |
creative_vision_analyzer | CLIP embeddings + LLM extract (hooks, format, UGC/studio, text overlay, faces). | Phase 2. Coût ~$0.001-0.005/créa. |
creative_clusterer | pgvector + HDBSCAN sur embeddings → familles créatives. | Phase 2. |
landing_page_matcher | Lie chaque créa à la PDP poussée. | Phase 2. |
ad_velocity_tracker | Days running, scaling, freeze/unfreeze. | Phase 2. |
Domaine E — Email & lifecycle (scope réduit)
| Worker | Méthode | Note |
|---|---|---|
popup_capture_extractor | Render homepage avec exit-intent, capture l'offre d'entrée. | OK légalement. |
signup_flow_extractor | Détecte les SDKs (Klaviyo Onsite, Privy, …) et les paramètres de capture. | OK légalement. |
klaviyo_benchmark_consumer | Si deal Klaviyo conclu : benchmark API officielle. | À explorer en Phase 3. |
email_honeypot_subscriber | Hors scope | Risque RGPD majeur. €20M / 4% global revenue. |
Domaine F — Traffic & audience
Phase 1 : on licence. SimilarWeb a une API + un MCP server officiel (similarweb.com/corp/ai/mcp). Plans Starter $149/mo (limité), Pro $399/mo, Enterprise ~$16K/an. Datos et Semrush sont des alternatives. Pour les stores connectés en OAuth, on remplace l'estimation par la vraie data Shopify Admin.
Phase 3+ : exploration d'un signal trafic propriétaire via :
- Referral network mapping (Ahrefs/Majestic backlinks)
- Social attention proxy (mentions IG/TikTok/Reddit)
- Ad spend visible → proxy directionnel
Domaine G — Pricing & commercial
| Worker | Description |
|---|---|
price_tracker | Changements de prix par variante dans le temps. |
discount_pattern_analyzer | Fréquence + profondeur discount, codes détectés. |
currency_market_detector | Currency switcher → marchés servis. |
shipping_policy_extractor | Seuils free shipping, zones, délais. |
subscription_detector | Recharge / Bold / Loop fingerprint. |
brand_age_estimator | Trois ancres : la date de création au registre (RDAP, sonde domain_registration), la première capture archive.org, le plus ancien published_at du catalogue. Le registre décide quand il répond — voir la note ci-dessous. |
La date de création : deux bornes, pas une mesure
La date d'ouverture d'une boutique n'est observable par aucune sonde. Trois signaux l'encadrent, et ils ne tombent pas du même côté :
| Signal | Sonde | Ce qu'il borne |
|---|---|---|
identity.domain_registered_at | domain_registration (RDAP) | borne basse — la boutique ne peut pas précéder l'adresse à laquelle elle est servie |
identity.first_archive_at | wayback | borne haute — la boutique existait au plus tard à cette date |
catalog.first_product_at | catalog | borne haute également, et instable : un ré-import de catalogue fait naître de nouveau une boutique de quatre ans |
La vraie date vit dans [registered_at, min(archive, product)]. Le hub
affichait le haut de cet intervalle : valisedemo.com sortait en
« Nov 2025 » là où le registre date le domaine de juillet. Le choix vit
désormais dans une seule fonction, creationAnchor (src/lib/hub/helpers.ts),
lue par les trois surfaces qui affichent une date de création (l'en-tête du
détail, la ligne de listing, le tri « nouvelles boutiques »).
RDAP (RFC 9082 / 9083) est le remplaçant IETF du WHOIS port-43 : HTTPS,
JSON, aucune clé, aucun contrat. La sonde ne figure donc dans aucun
tableau de intelligence-env-matrix.md,
il n'y a pas de variable à poser. Elle se saute elle-même une fois les deux
champs enregistrés : un évènement de création est immuable.
5.3 Couche storage
| Donnée | Store | Justification |
|---|---|---|
| Entités (Store, Product, Brand, Snapshot) | Neon Postgres (existant) | ACID, relations propres, déjà en place. |
| Time-series snapshots (catalog versions, prices, ad runs) | Neon Postgres avec partitions Phase 1, ClickHouse Phase 3 si volume >50M lignes | Évite l'ops d'un store supplémentaire jusqu'au moment où c'est vraiment nécessaire. |
| Embeddings (store positioning, products, créas) | pgvector + pgvectorscale sur Neon | 11.4× plus rapide que Qdrant à 50M vecteurs (tigerdata). Pas de service séparé. |
| Object brut (HTML rendu, screenshots, créas vidéo) | Vercel Blob (existant) | Coût marginal. |
| Cache hot-path | Upstash Redis (existant) | TTL court (5-30 min) sur les lookups récents. |
| Full-text + faceted filters (page discovery) | Postgres tsvector + GIN indexes Phase 1, Meilisearch Phase 2 si volume | Évite encore un store en v1. |
5.4 Couche inférence
sales_velocity_estimator
Modèle bayésien qui combine signaux pondérés par confiance :
P(orders/day | store) = weighted_combine([
inventory_delta_signal × C_inv, // 0.0 à 0.4 selon exposition publique
review_velocity_signal × C_review, // 0.3 à 0.6 selon plateforme
ad_spend_proxy_signal × C_ad, // 0.2 à 0.4
best_seller_rank_signal × C_rank, // 0.1 à 0.3
oauth_groundtruth_signal × 1.0 // 1.0 si store OAuth-connected
])
Pour les stores OAuth-connected : on utilise la vraie data Shopify Admin. C'est le ground truth.
Pour les stores non-connectés : on retourne la prédiction avec son intervalle de confiance. Pas un chiffre unique. L'outil de référence affiche un chiffre, on affiche 42 ± 18 orders/day, confidence 0.6. C'est plus honnête et c'est un signal différenciant.
calibration_engine
Pour chaque store OAuth-connected :
- À chaque scan complet, calcule la prédiction "comme si non-connecté"
- Compare à la vraie data Admin API
- Persiste l'erreur par dimension dans
AlgoTrace(table déjà existante !) - Une fois par semaine, recalcule les coefficients de pondération du modèle
À 100 merchants connectés on a un signal exploitable. À 1000 on bat SimilarWeb sur la précision Shopify spécifique. C'est notre moat irréplicable : ni l'outil de référence ni SimilarWeb n'ont accès à l'Admin API en masse.
5.5 Routing vers les agents
L'agent receveur est défini dans src/features/ai/agents/identity-registry.ts. Mapping confirmé :
| Agent | Rôle | Slice intelligence consommée |
|---|---|---|
| Atlas | Orchestrator (CRO) | Vue méta + revenue trajectory + anomalies cross-domain. Route les questions. |
| Maya | Traffic AI (Acquisition) | Ad creatives + creative families + testing cadences + landing match + pixels + traffic sources |
| Marco | Conversion AI (Merchandising) | Theme + apps + funnel fingerprint + pixel coverage + catalog structure + pricing + benchmarks niche |
| Otis | Lifecycle AI (CRM) | Email/SMS apps détectées + popup capture + signup flow + post-purchase apps + reviews lifecycle |
| Faye | Intelligence AI | Routeur intelligence + détection anomalies + fan-out aux autres agents. C'est l'agent qui détient le pipeline. |
| Sam | Retention AI (Support) | Reviews velocity + sentiment + social proof + support stack + post-purchase apps |
Note de correction : la CLAUDE.md actuelle décrit "Faye = Finance". Le code (identity-registry.ts) dit explicitement "Faye = Intelligence AI". On suit le code. La CLAUDE.md sera corrigée dans la même PR.
5.6 Surfaces produit
Surface 1 — Chat multi-agents
Dès qu'un user paste une URL dans le chat, OU dès qu'un merchant connecte son store Shopify :
- Atlas reçoit l'évent → délègue à Faye
- Faye lance
/api/intelligence/scan(SSE) - Pendant le scan, Atlas peut déjà dire au user : "Je suis en train d'auditer ton store, je reviens dans 15s avec un brief complet."
- Quand le scan est complet, Faye fan-out les slices aux agents pertinents
- Atlas synthétise un message d'audit + propose 3 actions priorisées
Le wow effect : la première vraie interaction avec @Atlas n'est jamais "comment puis-je t'aider ?". C'est un audit complet en 30s avec recommandations. Ça, aucun outil ne le fait aujourd'hui.
Surface 2 — Page discovery /[orgSlug]/intelligence
Reproduit l'UX de l'outil de référence mais nourrie par notre inférence :
Filtres v1 (parité de l'outil de référence) :
- Pays / langue / devise
- Niche (clustering vectoriel)
- Plage trafic / plage CA estimé (avec IC)
- Croissance % (7 / 30 / 90 j)
- Nombre d'ads actives
- Apps installées (multi-select)
- Thème détecté
- Pixels actifs
- Note Trustpilot / Judge.me
- Vélocité reviews
- Âge marque, nombre de produits, AOV
- Multi-pays oui/non
- Headless oui/non
Filtres différenciants v1 :
- "Stores en accélération" :
sales_velocity↑ 30%+ semaine sur semaine - "Anomalies récentes" : changement stack détecté
- "Similar to X" : vector search sur l'embedding store
- "Connected merchants only" : montre seulement les stores avec calibration ground truth → précision maximale (utile pour benchmarks internes)
Arme nucléaire UX — recherche en langage naturel :
"trouve-moi des stores skincare français qui scalent sur TikTok avec Klaviyo et qui font moins de 50 produits"
Parsing via LLM (Haiku ou Gateway) en filtres structurés → query Postgres + pgvector. L'outil de référence n'a pas ça, et c'est trivial à construire si les indexes sont bons.
La porte des surfaces — readStoreIntelligence
Depuis intelligence/2760 (audit
docs/audits/2026-09-17-une-source-de-verite-pour-le-store.md), une
surface qui veut « ce que BoostEcom sait d'un store » passe par
services/algorithms/intelligence/read-store-intelligence.ts : une
lecture Prisma, la porte de partage appliquée pour une audience
public, l'identité démasquée pour une audience member (l'appelant
a déjà vérifié l'appartenance), et deux formes en retour — record
pour une page qui itère les sections, hub (HubStore) pour tout
chiffre affiché. getItemByDomain lit par lui. Les lecteurs directs
restants sont énumérés par src/test/store-intelligence-has-one-reader.test.ts,
une liste qui ne peut que rétrécir.
Trois audiences, pas deux. Un AGENT TIERS (un client MCP branché sur
/api/mcp/intelligence) est plus restreint qu'un visiteur : la règle
est isAgentVisible, strictement PUBLIC, là où la page publique
accepte aussi UNLISTED. La différence est le sens d'UNLISTED — «
partagé par lien » veut dire que l'opérateur donnera le lien à
quelqu'un, et un porteur de jeton MCP n'est pas ce quelqu'un, d'autant
que ce qu'il demande est la projection par champ (secrets de stack, URL
de créatives, domaines d'envoi).
| Appelant | Porte | Visibilités admises |
|---|---|---|
| Page publique, fiche org | readStoreIntelligence(…, { audience: "public" }) | PUBLIC, UNLISTED |
| Agent tiers (MCP Intelligence) | readPublicIntelligence(domain) ou intelligenceIsPublic(domain) | PUBLIC |
| Le marchand, sur sa propre boutique | readStoreIntelligence(…, { audience: "member" }) | toutes, identité démasquée |
intelligenceIsPublic existe pour que la garde ne coûte pas une lecture
du blob JSONB quand l'appelant lit le record par une autre porte : elle
rend un booléen, jamais un champ. agentVisibilityFilter() est la même
règle en fragment where, pour les requêtes de liste.
Un enregistrement retiré et un domaine inconnu répondent la même chose à un agent. Les distinguer est un oracle : un appelant peut énumérer les domaines que nous détenons en les demandant un par un.
6. Cold-start strategy (T=0 à T=12+ mois)
À T=0 : zéro merchant connecté, zéro index propriétaire, zéro ground truth. La section §1 dit "on démarre on-demand" : correct mais incomplet. La vraie question : comment on délivre un wow effect dès le premier user, avant d'avoir notre propre data ?
Réponse : pendant 0-12 mois, on consomme des sources externes en backend, on les enveloppe dans notre orchestration multi-agents, et on construit progressivement notre propre couche. Pattern wrapper → puis émancipation.
6.1 Sources bootstrap disponibles à T=0
| Source | Coût | Usage | Légitimité |
|---|---|---|---|
Direct fetch endpoints publics Shopify (/products.json, /collections.json, sitemap) | Gratuit | 80% des stores, pas d'auth | OK — endpoint documenté Shopify, public |
| Meta Ad Library API | Gratuit (200 calls/h) | Tous paid ads actifs EU (DSA mandate) | OK — API officielle Meta |
| Firecrawl | $16/mo (Hobby 3K pages) à $83/mo (Standard 100K pages) | HTML + LLM extract (theme, apps, pixels, copy) avec anti-bot CF géré | OK — service commercial managed |
| Apify actors (Judge.me, Loox, Yotpo, Trustpilot, Shopify Product Scraper) | Pay-per-use ~$30-50/mo | Reviews + catalog scraping prêt à l'emploi | OK — marketplace officiel |
| MCP de tendances backend | $99-249/mo | Consommé en backend par Faye. Le user ne voit pas la source derrière. | OK — abonnement payant licite |
| SimilarWeb MCP backend | $149-399/mo | Trafic + audience pour stores non-connectés | OK — abonnement payant licite |
| Ta propre store Nacre Bijoux (déjà connectée) | $0 | Premier point de calibration ground truth | Acquis |
| Beta merchant program (10-50 merchants early access) | 6 mois gratuit en échange de calibration data | Premiers vrais points OAuth ground truth + feedback produit | OK avec consent UI explicite |
6.2 Trajectoire wrapper → émancipation
| Période | Stack data dominant | % propriétaire | % externe |
|---|---|---|---|
| T=0 à T=3 mois | MCP de tendances (backend) + SimilarWeb MCP (optionnel) + nos probes basiques (catalog, theme, apps) | 30% | 70% |
| T=3 à T=6 mois | Nos probes étendues (reviews, ads via Meta API, creas via vision) + l'outil de référence en fallback | 60% | 40% |
| T=6 à T=12 mois | Pipeline propriétaire dominant + 100-1000 merchants OAuth-connected → calibration active | 80% | 20% |
| T=12+ mois | Indépendance technique sur signaux Shopify-spécifiques. SimilarWeb reste consommé pour trafic global. | 90% | 10% |
C'est exactement le pattern que l'outil de référence a suivi (ils sont toujours wrapper SimilarWeb pour le trafic à T+3 ans). La différence : on annonce cette trajectoire en interne pour ne pas se perdre dans un narratif "tout propriétaire dès le jour 1" intenable.
6.3 Beta merchant program (recrutement calibration)
Phase 1-2 : on recrute 20-50 merchants Shopify en accès gratuit 6 mois contre :
- Consent explicite à utiliser leur data OAuth comme point de calibration (jamais publié, jamais partagé avec d'autres users, agrégats internes only)
- Feedback structuré sur les briefs intelligence
Cible : merchants $50K-$2M ARR (mid-market). Trop petit = pas de signal exploitable ; trop gros = on n'aura jamais accès.
Cette phase est non-tech mais critique stratégique : sans ground truth, l'inférence en Phase 2 sera médiocre. À traiter comme une couche du produit, pas comme du marketing.
7. Stack scraping — décision tranchée
Bench des 4 providers managed leaders 2026. Recommandation finale ci-dessous (cf. ma reco utilisateur confirmée).
7.1 Comparaison des providers
| Provider | Coût entrée | Coût scale (100K pages/mo) | Anti-bot CF | LLM extract natif | Use case BoostEcom |
|---|---|---|---|---|---|
| Firecrawl | $16/mo (Hobby 3K) | $83/mo Standard | ✓ Géré | ✓ /extract endpoint avec schéma | Primaire : HTML + extraction structurée robuste aux changements de thème |
| Browserbase | Pay-per-use | $0.10-0.12/h browser + proxy résidentiel $10-12/GB | ✓ Géré | ✗ (browser raw) | Secondaire : cas browser-heavy (interaction, scroll, CF challenge interactif). Déjà dans src/lib/browser/. |
| Scrapfly | $30/mo | $99-199/mo | ✓ Géré | ⚠ Via post-processing | Alternative à Firecrawl, plus enterprise. Pas d'avantage net en Phase 1. |
| Apify | Pay-per-use | ~$30-100/mo selon volume | ⚠ Variable selon actor | Via actors custom | Tertiaire : actors prêts Judge.me / Loox / Yotpo / Trustpilot. Évite 4-6 sem de redev. |
| Bright Data Scraping Browser | $0.05/h + proxy | $1000+/mo enterprise | ✓✓ Le plus robuste | ✗ | Quaternaire : tier 3 escalation pour <5% stores high-end protected. Pas Phase 1. |
7.2 Décision finale : stack hybride 4 tiers
Tier 0 — Direct fetch (Node undici)
→ Shopify public endpoints (/products.json, /collections.json, sitemap)
→ Meta Ad Library API
→ ~80% des cas accessibles ainsi, coût $0
Tier 1 — Firecrawl (primaire)
→ HTML rendering + LLM extract (theme, apps, pixels, copy)
→ Robustesse aux changements de thème Shopify
→ Budget : Hobby $16/mo → Standard $83/mo après volume
Tier 2 — Apify actors (spécialisés reviews)
→ Judge.me actor, Loox actor, Yotpo actor, Trustpilot actor
→ ~$30-50/mo pay-per-use Phase 1
→ Évite 4-6 sem de dev custom
Tier 3 — Browserbase (browser-heavy)
→ Interaction (scroll, click "view more reviews", checkout simulation)
→ ~15-20% des cas qui ne tiennent pas en HTTP simple
→ Déjà dans `src/lib/browser/`, aucune nouvelle intégration
Tier 4 — Bright Data Scraping Browser (escalade)
→ Stores avec Datadome ou Cloudflare Bot Management agressif
→ <5% des cas, coût ×10
→ Pas en Phase 1, on signale et on skip
7.3 Coût mensuel Phase 1 (estimation 200 scans/jour, 60% cache hit)
| Poste | Mensuel |
|---|---|
| Firecrawl Standard | $83 |
| Browserbase (proxy ~30% scans) | $40-80 |
| Apify (reviews scrapers) | $30-50 |
| MCP de tendances backend (cold-start hack §6) | $99-249 |
| SimilarWeb MCP (optionnel Phase 1) | $0 (skip) ou $149 |
| Bright Data (escalade) | $0 Phase 1 |
| Total Phase 1 sans SimilarWeb | ~$250-460/mo |
| Total Phase 1 avec SimilarWeb | ~$400-610/mo |
7.4 Pourquoi pas du tout-DIY ?
DIY (Selenium Stealth / Puppeteer Stealth / proxies résidentiels directs) = clampdown 2026 sur Cloudflare. Coût d'ops humain >> coût des services managed. ROI net : managed providers, on focus sur l'inférence et l'UX qui sont notre vrai moat.
8. Modèle Prisma
Extension de StoreReport (déjà existant) avec un nouveau type discriminant et un champ JSON typé via Zod. Pas de nouveau modèle pour minimiser la dette schema.
8.1 Schéma Prisma
model StoreReport {
id String @id @default(cuid())
storeId String
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
type StoreReportType
status StoreReportStatus
source StoreReportSource // NEW: tier scraping utilisé
result Json // Typé Zod en runtime (§9)
// Discovery-search optimization (NEW)
domain String? @db.VarChar(255)
niche String? @db.VarChar(120)
apps String[] // GIN index pour multi-select filter
themeId String?
countryCode String? @db.VarChar(2)
// Confidence & calibration (NEW)
overallConfidence Float? // [0.0, 1.0]
isGroundTruth Boolean @default(false) // True si OAuth-connected
// Embeddings vectoriels (NEW, pgvector)
embedding Unsupported("vector(1536)")?
scannedAt DateTime @default(now())
expiresAt DateTime? // TTL cache (§10)
@@index([storeId, type, scannedAt(sort: Desc)])
@@index([domain])
@@index([niche])
@@index([type, isGroundTruth])
@@index([apps], type: Gin)
// Vector index créé manuellement via migration :
// CREATE INDEX ON "StoreReport" USING hnsw (embedding vector_cosine_ops);
}
enum StoreReportType {
TRACKING // existant — scan tracking pipeline
COMPETITOR // existant
INTELLIGENCE // NEW
}
enum StoreReportStatus {
PENDING
RUNNING
SUCCEEDED
PARTIAL // NEW — certaines phases ont échoué, le brief reste utile
FAILED
}
enum StoreReportSource {
PROPRIETARY // notre pipeline
MARKET_MCP // wrappé en backend
SIMILARWEB_MCP // wrappé en backend
HYBRID // mix proprio + externe
}
8.2 Migration SQL
ALTER TABLE "StoreReport"
ADD COLUMN "source" TEXT NOT NULL DEFAULT 'PROPRIETARY',
ADD COLUMN "domain" VARCHAR(255),
ADD COLUMN "niche" VARCHAR(120),
ADD COLUMN "apps" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "themeId" TEXT,
ADD COLUMN "countryCode" VARCHAR(2),
ADD COLUMN "overallConfidence" DOUBLE PRECISION,
ADD COLUMN "isGroundTruth" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "embedding" vector(1536),
ADD COLUMN "expiresAt" TIMESTAMP(3);
CREATE EXTENSION IF NOT EXISTS vector;
CREATE INDEX IF NOT EXISTS "StoreReport_domain_idx" ON "StoreReport"("domain");
CREATE INDEX IF NOT EXISTS "StoreReport_niche_idx" ON "StoreReport"("niche");
CREATE INDEX IF NOT EXISTS "StoreReport_type_groundtruth_idx" ON "StoreReport"("type", "isGroundTruth");
CREATE INDEX IF NOT EXISTS "StoreReport_apps_gin_idx" ON "StoreReport" USING GIN("apps");
CREATE INDEX IF NOT EXISTS "StoreReport_embedding_hnsw_idx" ON "StoreReport" USING hnsw(embedding vector_cosine_ops);
ALTER TYPE "StoreReportType" ADD VALUE IF NOT EXISTS 'INTELLIGENCE';
ALTER TYPE "StoreReportStatus" ADD VALUE IF NOT EXISTS 'PARTIAL';
8.3 AlgoTrace réutilisé pour calibration
Le modèle existant capture déjà ce qu'il faut pour la calibration OAuth (cf. §5.4). Pas de migration ici. Champs expected (prédiction) + actual (Admin API ground truth) + metrics (erreur par dimension).
9. API contracts (TypeScript + Zod)
9.1 IntelligenceObject — shape de StoreReport.result pour type=INTELLIGENCE
À placer dans src/services/algorithms/intelligence/canonical/schema.ts :
import { z } from "zod";
export const ConfidenceInterval = z.object({
point: z.number(),
low: z.number(),
high: z.number(),
confidence: z.number().min(0).max(1),
});
export const CatalogSnapshot = z.object({
productCount: z.number().int(),
collectionCount: z.number().int(),
priceMedian: z.number(),
priceP90: z.number(),
currency: z.string().length(3),
publishedRecent7d: z.number().int(),
newProductsRecent30d: z.number().int(),
});
export const StorefrontStack = z.object({
theme: z.object({
id: z.string(),
name: z.string(),
isCustomized: z.boolean(),
version: z.string().optional(),
}),
apps: z.array(z.object({
handle: z.string(),
name: z.string(),
category: z.enum([
"email", "reviews", "upsell", "subscription", "shipping",
"search", "analytics", "popup", "loyalty", "wishlist", "other",
]),
confidence: z.number().min(0).max(1),
})),
pixels: z.array(z.object({
platform: z.enum(["meta", "tiktok", "google", "snap", "pinterest", "klaviyo_onsite"]),
id: z.string().optional(),
isServerSide: z.boolean().optional(),
})),
techStack: z.object({
cdn: z.array(z.string()),
headless: z.boolean(),
abTesting: z.array(z.string()),
customCheckout: z.boolean(),
}),
});
export const AdActivity = z.object({
meta: z.object({
activeAdsCount: z.number().int(),
spendBandEU: z.string().optional(),
audienceCountries: z.array(z.string()),
formats: z.record(z.string(), z.number()),
creativeCount: z.number().int(),
}).optional(),
tiktok: z.object({
visibleActiveAds: z.number().int(),
}).optional(),
});
export const ReviewsSignal = z.object({
platforms: z.array(z.object({
platform: z.enum(["judge_me", "loox", "yotpo", "stamped", "okendo", "trustpilot"]),
totalReviews: z.number().int(),
rating: z.number().min(0).max(5),
velocityPerDay: z.number(),
recent30dCount: z.number().int(),
})),
aggregateVelocityPerDay: z.number(),
});
export const SalesVelocityEstimate = z.object({
ordersPerDay: ConfidenceInterval,
aovEstimate: ConfidenceInterval,
revenuePerMonth: ConfidenceInterval,
modelVersion: z.string(),
signals: z.object({
inventoryDelta: z.number().nullable(),
reviewVelocity: z.number(),
adSpendProxy: z.number().nullable(),
bestSellerRank: z.number().nullable(),
oauthGroundTruth: z.number().nullable(),
}),
});
export const GrowthTrajectory = z.object({
classification: z.enum(["scaling", "stable", "decline", "unknown"]),
velocityChange7d: z.number().nullable(),
velocityChange30d: z.number().nullable(),
velocityChange90d: z.number().nullable(),
});
export const Anomaly = z.object({
type: z.enum([
"theme_change", "stack_change", "pixel_added", "pixel_removed",
"ad_burst", "restock_massive", "pricing_shift", "stockout_persistent",
]),
detectedAt: z.string().datetime(),
details: z.record(z.string(), z.unknown()),
severity: z.enum(["info", "notable", "high"]),
});
export const IntelligenceObject = z.object({
schemaVersion: z.literal("1.0"),
storeDomain: z.string(),
shopifyShop: z.string().optional(),
scannedAt: z.string().datetime(),
source: z.enum(["PROPRIETARY", "MARKET_MCP", "SIMILARWEB_MCP", "HYBRID"]),
catalog: CatalogSnapshot.nullable(),
storefront: StorefrontStack.nullable(),
ads: AdActivity.nullable(),
reviews: ReviewsSignal.nullable(),
salesVelocity: SalesVelocityEstimate.nullable(),
growth: GrowthTrajectory.nullable(),
anomalies: z.array(Anomaly),
overallConfidence: z.number().min(0).max(1),
isGroundTruth: z.boolean(),
partialPhases: z.array(z.string()),
});
export type IntelligenceObject = z.infer<typeof IntelligenceObject>;
9.2 Tools agents : signatures Zod pour specialists.ts
// src/features/ai/agents/tools/intelligence.ts
import { z } from "zod";
import { tool } from "ai";
export const auditStore = tool({
description: "Run a full intelligence audit on a Shopify store URL. Returns catalog, stack, ads, reviews, sales velocity estimate (with CI), growth trajectory, anomalies. Use for both the merchant's own store and competitor stores. Cached for 6h.",
inputSchema: z.object({
url: z.string().url(),
forceRefresh: z.boolean().default(false),
}),
execute: async ({ url, forceRefresh }, ctx) => { /* /api/intelligence/scan */ },
});
export const getCompetitorCatalog = tool({
description: "Get catalog snapshot of a competitor Shopify store: products, collections, pricing distribution, recent additions. Faster than full audit. Cached for 1h.",
inputSchema: z.object({
url: z.string().url(),
filters: z.object({
collection: z.string().optional(),
priceRange: z.tuple([z.number(), z.number()]).optional(),
publishedAfter: z.string().datetime().optional(),
}).optional(),
}),
execute: async ({ url, filters }) => { /* ... */ },
});
export const getCompetitorAds = tool({
description: "Get Meta + TikTok ad activity of a competitor: active ads count, spend bands EU (DSA), audience countries, top creatives. Cached for 4h.",
inputSchema: z.object({
url: z.string().url(),
platforms: z.array(z.enum(["meta", "tiktok"])).default(["meta", "tiktok"]),
sinceDays: z.number().int().min(1).max(90).default(30),
}),
execute: async ({ url, platforms, sinceDays }) => { /* ... */ },
});
export const searchSimilarStores = tool({
description: "Find Shopify stores similar to a given one based on semantic positioning (niche, customer type, price tier, USP). Phase 2.",
inputSchema: z.object({
referenceUrl: z.string().url(),
limit: z.number().int().min(1).max(50).default(10),
filters: z.object({
country: z.string().length(2).optional(),
minProductCount: z.number().int().optional(),
hasActiveAds: z.boolean().optional(),
}).optional(),
}),
execute: async ({ referenceUrl, limit, filters }) => { /* ... */ },
});
9.3 Routing par agent (Phase 1)
| Agent | Tools attribués Phase 1 | Slice IO consommée |
|---|---|---|
| Atlas | auditStore (orchestration) + délégation Faye | Vue méta + revenue trajectory + anomalies |
| Faye | auditStore, searchSimilarStores | Full IntelligenceObject |
| Marco | getCompetitorCatalog | catalog + storefront + pricing |
| Maya | getCompetitorAds | ads + storefront.pixels |
| Otis | (slice only Phase 1) | storefront.apps filtré email/popup + lifecycle apps |
| Sam | (slice only Phase 1) | reviews + social proof |
10. Cron & refresh policy
10.1 Tiers de priorité
État réel, dérivé de refresh-tiers.ts et de vercel.json. La table qui
figurait ici annonçait WARM toutes les 12h, COLD « pas de refresh auto » et un
tier FROZEN : les trois étaient faux.
| Tier | Stores éligibles | Cron | Mode de scan |
|---|---|---|---|
| HOT | Stores OAuth-connected des merchants BoostEcom, vivants | intelligence/refresh-hot, 0 */1 * * * | quick |
| hot-deep | Les mêmes, plus un working set spy-full (même barre de demande que WARM, plus un cooldown de 14j par store) | intelligence/refresh-hot-deep, 20 1,7,13,19 * * * | full |
| WARM | Stores externes PUBLIC vivants, ≥ 3 lookups en 7j | intelligence/refresh-warm, 0 */6 * * * | quick |
| COLD | Stores externes PUBLIC vivants portant déjà un maxLayer, non WARM | intelligence/refresh-cold, 30 3 * * * | quick |
Il n'y a pas de tier FROZEN : le sélecteur n'a jamais été écrit, et l'en-tête de
refresh-tiers.ts
explique ce que son absence coûte.
10.2 Implementation
Un seul job type Intelligence existe dans src/services/jobs/types.ts :
type JobPayloadMap = {
// ... existant
"intelligence-scan-store": {
domain: string
mode?: "quick" | "full" | "competitor"
probeSet?: "on_demand"
source?: string
storeId?: string
};
};
Les trois autres job types annoncés ici (intelligence-refresh-hot,
intelligence-refresh-warm, intelligence-calibrate-oauth) n'ont jamais été
écrits : les crons de refresh appellent directement runRefreshBatch.
Cron schedules réellement déclarés dans vercel.json :
{
"crons": [
{ "path": "/api/cron/intelligence/refresh-hot", "schedule": "0 */1 * * *" },
{ "path": "/api/cron/intelligence/refresh-warm", "schedule": "0 */6 * * *" },
{ "path": "/api/cron/intelligence/refresh-cold", "schedule": "30 3 * * *" },
{ "path": "/api/cron/intelligence/refresh-hot-deep", "schedule": "20 1,7,13,19 * * *" }
]
}
Il n'y a pas de cron intelligence/calibrate : ce chemin, annoncé ici en
0 4 * * 0, n'a jamais existé.
10.3 Runs ad-library : start-now / collect-later
Les actors ad-library (Apify) tournent dans un vrai navigateur et prennent des MINUTES. Aucune requête de scan ne peut attendre ça. Le contrat est donc : le provider démarre le run et rend la main, puis un collecteur lit le résultat plus tard.
C'est la moitié « lire plus tard » qui manquait, et c'est ce qui a brûlé le budget Apify :
| Étage | Ce qui se passait | Conséquence |
|---|---|---|
| Le handle du run vivait dans une clé KV à TTL 30 min | Rien ne le relisait avant 1h (tenant) ou 6h (store espionné) | Le handle expirait toujours avant qu'on lise le dataset |
| Aucun cooldown au démarrage (seulement à la complétion, jamais atteinte) | Le scan suivant repartait sur un nouveau run | Un run payé, jeté, puis repayé — en boucle |
| Le rapport opérateur disait « re-scanne dans quelques minutes » | Le re-scan démarrait un second run facturable | L'instruction de debug était elle-même une source de dépense |
Ce que ça donne maintenant :
- Un seul hash KV (
apify:adruns, champ<domain>::<network>) au lieu de N clés à TTL. Énumérable — un run que personne ne peut lister est un run que personne ne peut collecter. Voirad-library/pending-runs.ts. - Âge, pas TTL. Une entrée n'est jamais évincée en silence : elle n'est
lâchée que par le collecteur, après qu'il ait essayé (
MAX_RUN_AGE_MS= 3h). - Cooldown au démarrage. Un run est facturable dès qu'il est mis en file,
donc le domaine est marqué payé immédiatement (6h,
lib/provider-budget). - Cron dédié
intelligence/ad-runs-collect, toutes les 10 min : il draine le hash, lit les datasets des runsSUCCEEDED, écrit les créatives. Il ne démarre jamais d'actor, ce cron ne peut donc rien dépenser. - Par domaine, tout ou rien. Un domaine n'est ingéré que quand TOUS ses runs
(Meta + réseaux) ont abouti : les champs agrégés (
active_creatives,hook_clusters,primary_format) se calculent sur l'ensemble, donc ingérer Meta puis TikTok laisserait la plus petite écriture gagner. - Les non-Meta aussi. TikTok / Google / Pinterest appelaient encore
run-sync-get-dataset-items(bloquant) dans une probe plafonnée à 25s : même défaut, une copie en retard. Ils passent par le même registre.
Récupérer un run déjà payé : automatique
Les runs démarrés avant ce correctif ont réussi, écrit leur dataset et facturé
le compte : les datasets sont toujours chez Apify dans sa fenêtre de rétention.
ad-library/recover.ts
les rattrape, et le cron ad-runs-collect le fait tourner tout seul (balayage
throttlé à 1×/h, recoverPaidAdRunsIfDue).
C'est délibérément un cron et pas un bouton : ces datasets sont déjà payés et la fenêtre de rétention Apify tourne, donc une récupération qui attend que quelqu'un pense à la déclencher est une corvée avec une deadline, pas un correctif. L'auth cron de Vercel est le seul credential impliqué.
Les mêmes actions restent disponibles à la main pour forcer un passage ou inspecter avant d'écrire :
# Ce qui serait récupéré, sans rien écrire
POST /api/admin/intelligence/ad-runs {"action":"recover","apply":false}
# Forcer maintenant (le cron le fera de toute façon dans l'heure)
POST /api/admin/intelligence/ad-runs {"action":"recover"}
Le résultat du balayage apparaît sous recovery dans la réponse du cron :
visible dans /admin/platform/crons.
Deux façons pour un run de nommer son store : le champ boostecomTarget qu'on
estampille désormais dans l'input de chaque run (exact), ou, pour les runs
antérieurs, le mot-clé de recherche, réduit en slug et comparé au premier
label exact du domaine. Exact, pas préfixe : attribuer les créatives d'un
store à un autre est pire que laisser un run non récupéré.
GET /api/admin/intelligence/ad-runs liste ce qui est en file, avec l'âge de
chaque run.
10.4 Dedup idempotence
Le dedup se fait au niveau QStash, via deduplicationId, et les clés réelles
sont datées au jour UTC : ondemand:<rowId>:<YYYY-MM-DD> pour un re-scan
déclenché depuis une page détail, index:<domain>:<YYYY-MM-DD> pour
l'indexation d'un store (cf.
on-demand-warming.ts).
La clé intelligence-scan-${storeId}-${dayBucket} annoncée ici n'a jamais
existé.
11. Billing impact (modèle crédits BoostEcom)
Rien de cette section n'est en place. Aucun scan Intelligence n'est facturé en crédits :
reason: "intelligence_audit"(§11.4) n'apparaît nulle part dans le code, le modulecost-estimator.tsde la §11.3 n'a jamais été écrit, et les plafonds de la §11.2 citent un plan « Unlimited » que le catalogue (src/types/billing-plans.ts) ne connaît pas. À lire comme l'intention de 2026-05-12, pas comme l'état courant : la gratuité des scans est une décision produit de fait, non tracée ailleurs.
Le pipeline Intelligence consomme des crédits du merchant. Mapping conforme au modèle Credit ledger + pre-stream estimate + mid-stream hard cap déjà en place (cf. CLAUDE.md "garde-fous").
11.1 Mapping coûts → crédits
| Action | Coût brut estimé | Crédits facturés (markup 1.5×) |
|---|---|---|
| Audit on-demand cold path | $0.10-0.40 | 60 crédits ($0.60) |
| Audit hot path (cache hit) | <$0.001 | Gratuit |
| Refresh background HOT | $0.10 | Inclus dans abonnement Unlimited |
| NL search query (Haiku parsing + vector) | $0.005 | 5 crédits ($0.05) |
| Similar stores search | 1 vector query | 5 crédits ($0.05) |
11.2 Plafonds par plan
| Plan | Audits cold path inclus / mois | Recherches NL incluses / mois | Crédits achat consommables |
|---|---|---|---|
| Free | 1 (essai) | 5 | N/A (pas de BYOK) |
| Unlimited ($49/mo) | 50 | Illimité | Oui, markup 1.5× |
| Custom | Négocié | Illimité | Markup 1.0× |
11.3 Pre-stream estimate + mid-stream cap
Avant chaque scan cold path :
- Estimer le coût total (workers prévus + LLM extract)
- Si > balance crédits →
402 Payment Requiredavec proposition d'upgrade - Pendant le scan, abort si running cost dépasse le balance
Reuse de l'infra existante pre-stream estimate (cf. CLAUDE.md). Ajouter un module src/services/algorithms/intelligence/cost-estimator.ts.
11.4 Tracking dans Credit ledger
Append-only conventionné. Une ligne par scan facturé :
await db.credit.create({
data: {
orgId,
userId,
delta: -60,
reason: "intelligence_audit",
metadata: { storeId, intelligenceReportId, source: "PROPRIETARY" },
},
});
12. Privacy / RGPD / opt-out
12.1 Données collectées et bases légales
| Catégorie | Données | Base légale RGPD | Rétention |
|---|---|---|---|
| Données publiques de stores tiers (catalog, theme, pixels, ads) | Pas de données nominatives | Intérêt légitime + ToS publics | 12 mois rolling |
| Données nominatives de reviewers | Agrégats seulement, jamais nom/email individuels | N/A — agrégats anonymes | N/A |
| Données Admin API du merchant OAuth-connecté | Commandes, customers (PII), produits | Consentement explicite + contrat merchant | Selon contrat |
| Embeddings de stores | Vecteurs 1536d non-identifiants | Intérêt légitime | 12 mois rolling |
12.2 Flow opt-out concret
La page publique /intelligence/transparency
existe depuis juin 2026 (elle était encore annoncée ici « à créer ») et porte :
- Explication claire de ce qu'on fait (audit on-demand, pas de pré-indexage continu sauf cache)
- Formulaire opt-out : input URL store →
POST /api/intelligence/opt-out→ le store passe immédiatement enPRIVATEet l'opt-out est enregistré avecstatus: "pending"
Le modèle réel porte quatre champs que ce doc ignorait, et c'est eux qui font la différence entre un outil de conformité et une arme de délisting :
model IntelligenceOptOut {
id String @id @default(cuid())
domain String @unique @db.VarChar(255)
reason String?
ipHash String
requestedAt DateTime @default(now())
status String @default("pending") // "pending" | "verified"
verifyToken String?
verifiedAt DateTime?
method String? // "dns" une fois prouvé
priorVisibility IntelligenceVisibility?
@@index([domain])
@@index([status, requestedAt])
}
- Vérification de la demande : DNS TXT, pas email. Le demandeur publie
boostecom-verify=<verifyToken>sur le domaine, puisPOST /api/intelligence/opt-out/verifypasse la ligne enverified. Un opt-out non vérifié reste provisoire et expire (balayage dans le cronintelligence/prune-history) ;priorVisibilitypermet de le défaire sans republier un store que son propre propriétaire avait rendu privé. - Logging dans
AuditLog(modèle existant)
12.3 User-Agent identifiable et politique robots (hybride)
Ce paragraphe promettait l'UA BoostEcomIntelligence/1.0 (+https://boostecom.app/intelligence/transparency), le « respect strict
robots.txt » et 1s entre requêtes. Les trois étaient faux. L'état réel :
- User-Agent :
BoostEcom-Scanner/1.0 (+https://www.boostecom.app/about/scanner)(lib/fetch-providers/types.ts,DEFAULT_UA). C'est cette chaîne, et cette URL, qu'un opérateur verra dans ses logs ;/about/scannerrenvoie vers/intelligence/transparency. - robots.txt : politique hybride, décidée en août 2026
(
probes/shared/robots-policy.ts). Une lecture publique unique, celle qu'un visiteur déconnecté ferait (la home, UN/products.json, le favicon), est toujours autorisée et ne consulte pasrobots.txt. Seules les sondes agressives multi-pages (pagination catalogue, parcours de sitemap, balayage de pages de funnel) honorentDisallowpour notre UA. - Espacement :
MIN_HOST_INTERVAL_MS = 500, soit 500 ms par hôte, et uniquement sur le chemin agressif. - Couverture incomplète, assumée ici plutôt que masquée : seuls
catalog.tsetagentic-readiness.tspassent paraggressiveFetchAllowed/getRobotsRules.hidden,landings,sitemap_watcheretadvertorialspaginent aujourd'hui sans ce garde. Suivi dansbacklog/intelligence/0319.
12.4 Pas de honeypot email (rappel)
Cf. §3 et §17. Décision documentée et défendable en cas de question CNIL.
13. UI components spec (registry-first)
Conformément à la section « Stack UI canonique (registry-first) » de
CLAUDE.md, toute UI utilise la stack canonique (shadcn/ui, AI Elements, Motion v12, recharts, sonner, lucide). Pas de framer-motion, pas de react-hot-toast, pas de Modal/Tooltip custom.
13.1 Surface 1 : Audit panel dans le chat
Quand Atlas/Faye lance un audit en réponse à une URL collée :
| Composant | Source registry | Usage |
|---|---|---|
| Message agent streaming | ai-elements/message | Stream les phases au fur et à mesure |
| Phase indicator | ai-elements/reasoning + shadcn progress + lucide Loader2 | "Crawl catalog… ✓ / Detect apps… ⏳" |
| Section collapsible par domaine | shadcn accordion | Catalog / Stack / Ads / Reviews / Sales velocity |
| Card sommaire KPI | shadcn card | Headline numbers avec sparklines |
| Sparkline tendances 7/30j | recharts via shadcn chart | Ad velocity, review velocity |
| Confidence interval bar | custom basé sur shadcn progress + tooltip | 42 ± 18 orders/day · conf 0.6 |
| Animation entry | motion/react | Stagger reveal des sections au streaming |
| Empty/partial state | shadcn alert + lucide AlertCircle / Info | "SimilarWeb returned no traffic data" |
| Toast à la complétion | sonner | "Audit complete · 9 / 11 phases succeeded" |
13.2 Surface 2 : Page discovery /[orgSlug]/intelligence
Layout type liste filtrable façon outil de référence :
| Composant | Source registry | Usage |
|---|---|---|
| Search bar (NL + structured) | shadcn input + popover + command | NL query → filtres extraits visibles |
| Filter panel sticky | shadcn sheet (mobile) / pane latéral (desktop) | 15-20 filtres en multi-select |
| Filter chips multi-select | shadcn command + badge | Apps, themes, pixels |
| Range sliders | shadcn slider | CA estimé, croissance %, AOV |
| Toggle groups | shadcn toggle-group | Multi-pays oui/non, Headless oui/non |
| Result grid | Tailwind grid + shadcn card | Liste stores avec preview |
| Sort dropdown | shadcn select | Trier par CA, croissance, ad activity, similarité |
| Pagination | shadcn pagination | Page-based |
| "Connected" badge | shadcn badge default + lucide ShieldCheck | Stores avec ground truth |
| "Estimated" badge | shadcn badge outline + lucide BarChart3 | Stores avec inference seule |
| Hover card preview | shadcn hover-card | Quick peek au survol |
| Empty state | shadcn empty + lucide | "0 résultat" |
| Toast erreur | sonner | "Recherche échouée" |
13.3 Surface 3 : Store profile /[orgSlug]/intelligence/[domain]
Cliquer sur un store dans la discovery ouvre un profile détaillé :
| Composant | Source registry | Usage |
|---|---|---|
| Header sticky logo + KPIs primaires | shadcn card + custom layout | Domain, niche, CA estimé (avec CI), growth tag |
| Tabs sections | shadcn tabs | Overview / Catalog / Stack / Ads / Reviews / Anomalies |
| Time-series charts | recharts via shadcn chart | Ad velocity, review velocity, price history |
| Catalog table | shadcn table | Produits avec sortable columns |
| Apps grid | shadcn card × N | Apps détectées avec catégorie + confidence |
| Anomaly timeline | custom + motion/react | Anomalies datées avec severity color |
| Comparison overlay | shadcn dialog ou sheet | Comparer ce store avec un autre (Phase 2) |
| Action buttons | shadcn button | "Analyser avec @Atlas", "Ajouter au watchlist" |
| Confidence badges everywhere | custom sur badge + tooltip | Sur chaque chiffre, IC au hover |
13.4 Surface 4 : Public scan results /scan/intelligence/[id]
Réutilise le pattern existant /scan/tracking/[id] (src/app/(minimal)/scan/tracking/[id]/page.tsx). Adapter pour l'IntelligenceObject. Soft auth gate après 2-3 scans gratuits (pattern déjà conventionné dans le repo).
13.5 Loading & error states
| État | Composant |
|---|---|
| Scan en cours (cold path 15s) | shadcn skeleton × N + motion/react stagger reveal |
| Partial result | shadcn alert variant default + Info + sections affichées sans failed |
| Total failure | shadcn alert variant destructive + XCircle + retry button |
| Quota dépassé | shadcn alert + CTA upgrade + lien /pricing |
13.6 Mobile responsive
- Filter panel
sheetslide-in - Result grid 1 col mobile, 2-3 col desktop
- Tabs scrollables horizontalement
- Charts recharts responsive
13.7 Dark mode
next-themes (déjà en place). Tous les composants shadcn supportent natif. Charts recharts utilisent les CSS vars Tailwind 4.
14. Testing strategy
14.1 Fixtures de stores connus
Captures HTML/JSON statiques de 20-30 stores réels, stockés dans src/services/algorithms/intelligence/__fixtures__/. Permet :
- Tests de regression sur les probes (parsing theme, apps, pixels)
- Tests sans hitting le réseau en CI
- Golden files pour l'
IntelligenceObjectde chaque fixture
14.2 Tests par couche
| Couche | Type | Outils |
|---|---|---|
| Probes individuelles | Unit tests sur fixtures | vitest |
| Orchestrator fan-out | Integration tests (probes mockées) | vitest |
| Inference engine | Golden tests (in/out fixture pairs) | vitest |
API route /api/intelligence/scan | E2E avec MSW | vitest + msw |
| Calibration vs vraie data | Property-based avec Nacre Bijoux en ground truth réelle | vitest + fast-check |
| Page discovery filtres | E2E navigateur | playwright |
14.3 Calibration regression tests
Phase 2+, une fois la calibration OAuth active :
- Pour chaque store OAuth-connected du calibration set, calcule la prédiction
- Compare à la vraie data Admin API
- Assertion : erreur médiane < seuil (à définir empiriquement, ex. 30% sur revenue/month)
- Run en CI weekly, alerting si dérive
15. Observability
15.1 OpenTelemetry spans
Le repo utilise déjà Vercel OpenTelemetry. Instrumentation :
intelligence.scan (root span)
├── intelligence.cache_check
├── intelligence.shop_resolver
├── intelligence.fan_out
│ ├── intelligence.probe.catalog
│ ├── intelligence.probe.theme
│ ├── intelligence.probe.apps
│ ├── intelligence.probe.pixels
│ ├── intelligence.probe.reviews
│ └── intelligence.probe.meta_ads
├── intelligence.inference
│ ├── intelligence.inference.sales_velocity
│ └── intelligence.inference.growth_trajectory
└── intelligence.persist
Attributs span :
store.domain,store.shopify_shopscan.source(PROPRIETARY/MARKET_MCP/ etc.)scan.tier(1 direct / 2 Firecrawl / 3 Browserbase)scan.cache_hit(bool)scan.cost_estimate_centsscan.confidence
15.2 Métriques structured logger
| Métrique | Type | Alerte |
|---|---|---|
intelligence.scan.duration_ms | histogram | p95 > 25s |
intelligence.scan.cost_cents | histogram | p95 > 150 |
intelligence.scan.cache_hit_rate | gauge | < 30% sur 24h |
intelligence.scan.failure_rate | gauge | > 5% sur 24h |
intelligence.scan.partial_rate | gauge | > 20% sur 24h (probe dégradée) |
intelligence.probe.{name}.failure_rate | gauge | > 10% (probe à fixer) |
intelligence.calibration.median_error | gauge | > 50% (drift modèle) |
15.3 Cost-alert cron
Le repo a déjà src/app/api/cron/cost-alerts/. Étendre pour :
- Total mensuel intelligence services (Firecrawl + Browserbase + Apify + MCP de tendances)
- Alerting si > budget configuré dans
serverEnv.INTELLIGENCE_MONTHLY_BUDGET_CENTS
16. Phasing
Phase 1 — Audit on-demand (3-4 semaines)
Scope : un user submit une URL ou un merchant connecte son store → on retourne un brief complet en ≤ 15s.
Workers livrés :
shop_resolver,catalog_full_crawler,sitemap_watchertheme_detector,app_detector(top 50 apps),pixel_detector(réutilisetracking/scan)meta_ad_library_fetcherbasique (rate-limited à 200 calls/h, queue)review_velocity_aggregator(Judge.me + Loox + Yotpo)sales_velocity_estimatorv1 (sans calibration encore : coefficients par défaut)
Infra livrée :
- Route
/api/intelligence/scan(SSE) — patterntracking/scanréutilisé - Extension de
StoreReportavectype="INTELLIGENCE"+ champ JSON typé - 3 tools dans
specialists.ts:auditStore(url)(Atlas/Faye),getCompetitorCatalog(url)(Marco),getCompetitorAds(url)(Maya) - Page minimale
/scan/intelligence/[id](réutilise le pattern/scan/tracking/[id])
Livrable démontrable : un merchant arrive, paste une URL, voit en 15s un audit complet. Démo ready pour pitch.
Ce qu'on ne fait PAS en Phase 1 :
- Pas de pré-indexage continu (uniquement on-demand)
- Pas de page discovery filtrable
- Pas de calibration OAuth active (on logue les écarts, on ne ré-entraîne pas encore)
- Pas de creative vision analyzer
- Pas de honeypot email
- Pas de ClickHouse, pas de Qdrant, pas de Meilisearch
Phase 2 — Persistence & inference (8-12 semaines)
Scope : on construit l'avantage durable.
catalog_delta_watcher+variant_inventory_sniffercreative_vision_analyzer+creative_clusterer(CLIP + HDBSCAN sur pgvector)sales_velocity_estimatorv2 avec calibration OAuth activegrowth_trajectory+anomaly_detector- Page discovery v1 avec ~10 filtres + similarity search
- Background jobs : recrawl périodique 1-6h selon priorité
- Embeddings store positionnement (pgvector namespace
store-intelligence)
À ce stade on tient face à l'outil de référence sur ses cas d'usage core, et on commence à les dépasser sur la précision Shopify-spécifique.
Phase 3 — Scale & moat (3-6 mois)
- Recherche en langage naturel sur la page discovery
- Tous les workers pricing/commercial
klaviyo_benchmark_consumer(si deal Klaviyo)social_metrics_fetcher(followers, engagement, cadence)- Migration ClickHouse pour time-series (si volume >50M lignes)
- Migration Meilisearch (si index >100K stores)
- Exposition MCP en lecture pour partenaires/agences (sélectif, payant)
Phase 4+ — Vision long terme
- Panel trafic propriétaire (si volume merchants connectés justifie l'investissement)
- API publique
- Marketplace de signaux (deals avec acteurs verticaux)
17. Risques & garde-fous
7.1 Légal / RGPD
| Risque | Mitigation |
|---|---|
Scraping massif /products.json 24/7 sur stores tiers → zone grise ToS Shopify | On scrape on-demand (un user le demande). Pré-indexage limité aux stores fréquemment demandés. User-Agent identifiable BoostEcomIntelligence/1.0 (+contact). Respect robots.txt. |
| Stocker données nominatives (reviewers, emails honeypot) → RGPD | Pas de honeypot en v1. Pas de stockage de noms/emails de reviewers individuels — agrégats uniquement. |
| Concurrence déloyale / parasitisme (jurisprudence FR) | Donnée brute publique + analyse propriétaire. On ne republie pas la donnée, on l'analyse. |
| Risque réputationnel si merchant découvre qu'on a son store en base sans qu'il soit user | Page transparence /intelligence/transparency + opt-out par URL submitted. Tag noindex sur les profiles publiques. |
7.2 Anti-bot
99.2% des stores Shopify sont derrière Cloudflare. Open-source stealth plugins (Selenium Stealth, Puppeteer Stealth, Playwright Stealth) sont clampdown en 2026 (scrapfly).
Stratégie :
- Path tier 1 (~80% des stores) : fetch direct
/products.jsonavec User-Agent identifiable + délais. Pas besoin de bypass. - Path tier 2 (~15%) : Browserbase ou Scrapfly managed API avec proxy résidentiel ($0.10-0.12/h browser + $10-12/GB proxy).
- Path tier 3 (~5% high-end) : escalade Bright Data Scraping Browser ou équivalent. Coût ×10.
Budget infra estimé Phase 1 : $0.50 à $1.50 par lookup à froid, $0.001 amorti après cache.
7.3 Précision
| Risque | Mitigation |
|---|---|
| User compare nos chiffres à ses vraies datas Shopify et trouve un écart | Toujours afficher l'intervalle de confiance. Jamais un chiffre unique. Pour les stores OAuth-connected, afficher "ground truth" + delta vs notre prédiction. |
| Stores <5K visites/mois → SimilarWeb retourne vide / l'outil de référence invente | On hérite si on consomme leur API. Pour nos signaux propres, on les exclut sous un seuil de confiance minimal. |
| Inférence sales velocity biaisée sans calibration | Phase 1 : on annote "v1 model, calibration coming in Phase 2". On est honnête. |
7.4 Coûts unitaires (Phase 1, on-demand)
| Poste | Coût par lookup |
|---|---|
| Compute + scraping infra | $0.02-0.05 |
| Browser time (cas tier 2) | $0.05-0.10 |
| LLM extract (Haiku via Gateway) | $0.001-0.005 |
| Meta Ad Library API | $0 (rate-limited) |
| Traffic API (SimilarWeb) | $0.05-0.20 si activé |
| Total cold path | $0.10-0.40 |
| Total hot path (cache) | <$0.001 |
À comparer au prix de l'abonnement BoostEcom (Unlimited à $49/mo). Pour ne pas se faire saigner, plafond à 50 lookups cold path / mois inclus dans le plan, au-delà → décompté en crédits (markup 1.5×).
7.4.2 Les aperçus de boutique — une capacité, pas un proxy ouvert
/api/intelligence/og-image sert l'image d'aperçu d'un store. Son en-tête
promettait de ne proxifier que « l'og:image de la boutique elle-même » et
n'appliquait aucune contrainte : il lisait ?url= dans la query string
et servait n'importe quelle URL https:// qui passait le garde SSRF et
répondait avec un image/*. Pas de prisma, pas de StoreIntelligence,
pas de signature. C'était un proxy d'images ouvert, caché 24 h par le CDN,
8 Mo par requête, 120 requêtes/min/IP, sur notre bande passante.
Deux mécanismes rendent la phrase vraie, dans cet ordre :
- Contrainte à la capture / projection.
hub-projectionne publieog_imageque si l'asset est sur l'hôte de la boutique (ou un CDN Shopify). Sans ça, une boutique indexée qui hotlinke l'image d'un tiers transformerait la projection en oracle de signature. - Capacité. Ce qui survit est signé (
signOgImageUrl, HMAC-SHA256 tronqué à 128 bits, cléNEXTAUTH_SECRET), et la route vérifie avant le fetch : une URL non signée ne coûte même pas la requête sortante.
La clé est délibérément différente d'INTELLIGENCE_ANON_SECRET : celle-ci
sert les identifiants irréversibles et anon-secret.ts est le seul module
autorisé à la lire. Sans clé, rien n'est signé donc rien n'est servi — la
tuile retombe sur le favicon. Ça échoue fermé, ce qui est la seule
direction acceptable pour une capacité.
backlog/intelligence/0317 (intelligence-surfaces-10), gardes
src/lib/security/og-image-token.test.ts et
src/app/api/intelligence/og-image/route.test.ts.
7.4.1 Ce que dépense une recherche anonyme (état du code, 2026-09-05)
/api/intelligence/hub/scan ne demande aucun compte. Ce qu'il dépense
est décidé par deux listes de services/algorithms/intelligence/shopify-deep-scan.ts
et par reserveOnDemandPaidScan.
| Sondes | Dépense | |
|---|---|---|
ON_DEMAND_PROBES (réservation accordée) | quick + wayback + domain_registration + emails + reviews_vendor + traffic_provider + storefront_screenshot + social_metrics_fetcher (+ meta_ad_library_fetcher si une source pub est clé) | plusieurs renders, dont deux sur le pool stealth |
ON_DEMAND_FREE_PROBES (réservation refusée) | le même set moins ON_DEMAND_PAID_PROBES | un render Firecrawl, pas zéro |
La deuxième ligne est le point qui a été faux en commentaire pendant des
mois. Six sondes de quick — app_detector, pixel_detector,
analytics, reviews_aggregation, social_handle_extractor,
popup_detector — appellent probeFetch(base, { requireRenderedHtml: true }),
et lib/fetch-providers jette une réponse direct valide dès qu'un
provider de rendu est configuré. quick est dans tous les sets. Le
cache de réponse par scan (cacheKeyFor(url, rendered, stealth)) fusionne
ces six requêtes identiques en un seul appel : le plancher est d'un
render, pas de six, et il est nul seulement si aucun renderer n'est câblé.
Ce qui justifie une place dans ON_DEMAND_PAID_PROBES est donc une
dépense que cette entrée de cache ne peut pas servir : une autre URL,
stealth: true, un appel direct à api.firecrawl.dev, ou un acteur Apify
payant. emails n'en fait pas partie — son unique fetch est la homepage
déjà payée par app_detector. La règle est dérivée des modules de sondes
par shopify-deep-scan-spend.test.ts, pas maintenue à la main.
Conséquence non résolue : la réservation quotidienne borne le surplus, pas
le plancher. Un scan refusé coûte toujours un render, borné uniquement par
le rate limit de la route (8/h/IP). Trois options chiffrées dans
backlog/intelligence/0354.
18. Décisions tranchées avant Phase 1 (historique)
Section close. La Phase 1.5 a été livrée le 2026-05-22 (§20) et la Phase 2 a suivi (§21) ; chaque ligne ci-dessous porte déjà sa recommandation, et le point « Corriger CLAUDE.md (Faye=Intelligence) » est appliqué depuis. Le titre a continué d'annoncer des décisions ouvertes des mois après leur clôture.
| Décision | Options | Recommandation |
|---|---|---|
| Scope Phase 1 minimal ou étendu ? | Minimal (4 phases scan, 3 tools) vs étendu (+ creative vision + discovery v1) | Minimal. Livre dans 3-4 sem, démontrable, dette tech minimale. |
| Licence SimilarWeb wholesale dès Phase 1 ? | Oui ($149-399/mo) ou non | Non en Phase 1. On se concentre sur ce qu'on fait mieux, on ajoute SimilarWeb en Phase 2 si nécessaire pour les démos. |
| Tranché §7 | Stack hybride 4 tiers : direct fetch + Firecrawl primaire + Apify (reviews) + Browserbase (browser-heavy). | |
Nouveau modèle CompetitorScan ou extension de StoreReport ? | Nouveau modèle vs type="INTELLIGENCE" | Extension StoreReport. Pattern déjà utilisé (TRACKING, COMPETITOR). |
| Page discovery dans Phase 1 ou Phase 2 ? | Phase 1 minimale vs Phase 2 | Phase 2. Trop large pour 3-4 sem si on veut un audit on-demand solide. |
| Corriger CLAUDE.md (Faye=Intelligence) dans la même PR ? | Oui / non | Oui. La doc est en retard sur le code, ça crée de la confusion (cf. mon erreur de routing initiale). |
19. Décision honnête finale
On ne fait pas "mieux que l'outil de référence + SimilarWeb réunis" au sens absolu. On fait différent et structurellement supérieur sur la dimension qui compte pour notre cible (les merchants Shopify connectés) :
- Plus précis sur les stores connectés (ground truth Admin API)
- Plus actionnable (les agents mutent le store, ils ne montrent pas que des chiffres)
- Plus intelligible (équipe de 6 agents experts vs un dashboard avec 200 filtres)
- Plus honnête (intervalle de confiance explicite, pas un chiffre unique inventé)
C'est ce positionnement qu'on défend. Le reste (couverture mondiale, panel trafic, historique 5 ans) on l'accepte comme acquis chez eux et on n'essaie pas de répliquer.
Sources validées
- Shopify scraping methods 2026 — DEV
- Cloudflare bypass landscape 2026 — Scrapfly
- Meta Ad Library EU DSA requirements — Transparency Center
- TikTok Commercial Content API — TikTok Devs
- SimilarWeb MCP server — page officielle
- pgvector vs Qdrant 50M benchmark — TigerData
- Browserbase pricing 2026
- Email scraping GDPR risks — Apollo
- Shopify theme/app detection — ecomm.design
20. Phase 1.5 — Mega-PR (livrée 2026-05-22)
Mise à jour majeure shipée sur la branche claude/elegant-sagan-TRd77. Boucle 21 items du backlog audit (cf. conversation utilisateur), exécutée en 12 commits atomiques. Tout en TS coté coeur ; pas de Python ajouté. Tous les changements de schéma Prisma sont additifs (aucun drop).
Décisions architecturales clés (corrections à la spec initiale)
| Spec d'origine §X | Décision Phase 1.5 | Raison |
|---|---|---|
§5.3 pgvector + pgvectorscale sur Neon | Upstash Vector namespace intelligence:stores | La spec D5.5 de docs/architecture/memory-layer.md interdit explicitement pgvector : "We do NOT introduce pgvector to Neon — splits the vector surface across two stores". On réutilise l'infra existante. |
§8.1 Extension de StoreReport avec type="INTELLIGENCE" | Modèle dédié StoreIntelligence (déjà créé en PR #97) | Découplage propre, visibilité gate dédié, indexes natifs sans type discriminant. |
| §16 Phase 1 "Faye = Intelligence routing à corriger dans CLAUDE.md" | CLAUDE.md + 6 locales + tous les call-sites code relabellés | "Faye = Finance" était devenu un patchwork — fix complet en 1 passe, ID interne ag-spec-finance préservé pour pas migrer OrgFact/StoreFact/UserFact.agentId. |
§5.4 sales_velocity_estimator v2 avec calibration active | Phase 1.5 plumbe la calibration end-to-end mais sans flipper les multipliers (MIN_SAMPLES_FOR_ADJUST=30) | Calibration scaffold était déjà créé ; manquait juste la connection au Shopify Admin via ShopifyClient.fromProject. |
Livraison item-par-item
| # | Item brief / backlog | Livraison Phase 1.5 |
|---|---|---|
| 1 | Faye = Intelligence (relabel complet) | refactor(agents) — specialists.ts, atlas-router.ts, team-context/roster, identity-registry, mascots, CLAUDE.md, 6 locales i18n |
| 2 | Wire calibration fetchShopifyMonthlyRevenue/Units | feat(intelligence) — ShopifyClient.fromProject + shopifyqlQuery sales report, 200-entry LRU cache partagée |
| 3 | auditStore → naming clarifier | différé (autre agent travaille dessus) |
| 4 | schema_version sur RecordMeta | feat(intelligence) — CANONICAL_SCHEMA_VERSION = "1.0.0" stamped + préservé par le reconciler |
| 5 | embedding vector(1536) + HNSW index | Corrigé : Upstash Vector namespace intelligence:stores au lieu de pgvector. embeddings.ts projette le record en narrative, fire-and-forget sur le persist path. |
| 6 | searchSimilarStores tool agent + MCP | Tool agent + MCP route — gate PUBLIC visibility, fallback re-embed quand reference n'a pas de record |
| 7 | getCompetitorCatalog(url) + getCompetitorAds(url) | Tools agents Marco/Maya — déclenchent un scan focused (catalog-only / ads-only) si record stale > 24h |
| 8 | Refresh tiers HOT / WARM / COLD | 3 cron routes + refresh-tiers.ts + refresh-runner.ts partagé + IntelligenceLookupTally Prisma |
| 9 | Anomaly detector | inference/anomaly-detector.ts (8 types — theme_change, stack_change, pixel_added/removed, ad_burst/freeze, restock_massive, pricing_shift, review_burst, social_traction_spike) + StoreAnomaly Prisma |
| 10 | Domaine G — 3 probes pricing | price-tracker, discount-pattern-analyzer, shipping-policy-extractor + extension CommerceSection |
| 11 | catalog_delta_watcher + variant_inventory_sniffer | 2 nouveaux probes + catalog.recent_diffs / catalog.inventory_velocity + CatalogDelta Prisma |
| 12 | Merchant private dashboard /[orgSlug]/intelligence | List + [domain] profile avec 6 tabs (Overview / Catalog / Stack / Ads / Reviews / Anomalies) + IntelligenceWatchlist Prisma (retiré depuis, voir §27) |
| 13 | NL search sur /intelligence | /api/intelligence/nl-search — Haiku parse → ParsedFilters → query Postgres + canonical record match |
| 14 | Creative vision + clustering | creative-vision-analyzer.ts (Sonnet 4.6 vision + Zod schema, budget cap env var) + creative-clusterer.ts (cosine single-link agglomerative, no external ML lib) + AdCreativeAnalysis Prisma + ads.creative_clusters (L4) |
| 15 | MCP Phase 3 OAuth | IntelligenceApiKey Prisma + /api/intelligence/api-keys CRUD + /[orgSlug]/~/settings/api-keys UI + MCP route auth (Phase 3 + legacy Phase 2 fallback) |
| 16 | Beta consent program scaffolding | IntelligenceBetaConsent Prisma + /[orgSlug]/intelligence/beta UI + consent endpoint |
| 17 | Traffic provider — wholesale adapters (priorité user) | traffic-providers/{similarweb,datos,semrush}.ts adapters + traffic-provider.ts probe v0.2.0 route wholesale-first avec fallback scrape |
| 18 | Klaviyo benchmark consumer | providers/klaviyo-benchmarks.ts + bundled snapshot 13 niches |
| 19 | Bright Data tier-3 anti-bot | lib/browser/bright-data-adapter.ts — Playwright CDP, budget cap mensuel via Redis bucket |
| 20 | ClickHouse abstraction | time-series/index.ts — interface TimeSeriesStore + driver Postgres, ClickHouse stub gated par CLICKHOUSE_URL |
| 21 | Bulk indexer pour millions de stores | bulk-indexer/index.ts — enqueueSeedList + enqueueOperatorBatch, freshness gate 30 jours, seed list top-shopify-2026 (30 domains) |
| 22 | Panel propriétaire + BoostEcom Spy bridge | /api/intelligence/panel/ingest (HMAC-SHA256 signature) + IntelligencePanelEvent Prisma (partitionKey YYYY-MM pour partitions natives futures) |
Bilan de couverture
- Probes : le compte qui fait autorité est
Object.keys(PROBES).length(probes/index.ts) et la taille dePROBE_SETS.full(shopify-deep-scan.ts) ;adsest un alias enregistré versmeta_ad_library_fetcheret reste horsfull. Les « 31 enregistrés / 30 exécutés » écrits ici à la main étaient faux au moment de l'audit du 2026-09-03. - Sections canoniques : 10 inchangées, 4 étendues (catalog +2, ads +1, commerce +4, inferred +1)
- Modèles Prisma : +9 (
StoreAnomaly,CatalogDelta,IntelligenceWatchlist,IntelligenceLookupTally,IntelligenceApiKey,IntelligenceBetaConsent,AdCreativeAnalysis,IntelligencePriceObservation,IntelligencePanelEvent) - Crons : 1 → 4 (intelligence-tick + 3 tier crons)
- Tools agents : 3 → 6 (
searchStoreIntelligence,listKnownStores,searchSimilarStores,getCompetitorCatalog,getCompetitorAds) - MCP tools exposés : 4 → 10 au 20 août 2026 (
searchSimilarStores, puisgetStoreGraph,predictStoreTrajectory,findEmergingNiches,getWinningAngles,getSupplierIntel), source :TOOLSdanssrc/app/api/mcp/intelligence/route.ts - Surfaces produit : 1 → 5 (
/intelligencepublique +/[orgSlug]/intelligenceprivée +[domain]profile +/intelligence/betaconsent +/~/settings/api-keys)
Ce qui reste user / procurement (pas code)
- Beta merchant recruitment (#16) : UI prête, contrat & outreach côté ops
- SimilarWeb / Datos / Semrush contracts (#17) : adapters prêts, env vars déclarées, signature contrat côté ops
- Klaviyo partnership (#18) — consumer + fallback bundle prêt, deal Klaviyo côté ops (non bloquant grâce au snapshot)
- Bright Data subscription (#19) : adapter prêt, account création côté ops
- Bulk crawl execution (#21) : infra prête, lancement crawl effectif (cost & time) côté ops
- ClickHouse provision (#20) — abstraction prête, instance ClickHouse côté ops
21. Phase 2 — Fermeture des gaps marché (audit de l'outil de référence)
Cible : passer de "parité de l'outil de référence + précision Shopify" à la couverture des manques nommés du marché (audit utilisateur). Quatre features livrées end-to-end, additives, sans migration destructive. Le reconciler backfille les sections/champs ajoutés sur les records existants (forward-compat, zéro migration de données).
21.1 SEO Intelligence (gap G)
Nouvelle section canonique seo. Probe seo (full + competitor) :
- On-page (toujours, gratuit, depuis le HTML déjà fetché) : title, meta description, h1, canonical, hreflang count, JSON-LD @types, word count, internal links, image-alt ratio, indexabilité, keywords minés (titres + collections), top pages (sitemap catégorisé).
- Off-page (quand une clé provider est posée) : organic keywords/traffic, backlinks, referring domains, domain rating, top ranking keywords, via
seo-providers/{ahrefs,semrush,dataforseo}.ts(résolution par priorité Ahrefs > Semrush > DataForSEO, fail-soft, pattern identique àtraffic-providers).
21.2 Store Graph + Clone Intelligence (gaps H + K : "le plus gros manque du marché")
Nouvelle section canonique graph + modèle Prisma StoreSignalIndex (index inversé (domain, kind, value)). Le graph-builder tourne post-inférence dans l'orchestrateur (accès DB, ni probe ni inférence pure) :
- indexe les signaux du store (tracking ids GA4/Meta/Klaviyo/GTM/…, theme_store, apps, social handles, top product handles),
- interroge l'index inversé pour les domaines partageant ces signaux,
- classifie via le scorer pur (
graph/scorer.ts, testé) :same_owner(ids compte partagés = même opérateur),same_network(theme + stack apps = agence/template),clone(overlap produits/créatives),lookalike.
Émet same_owner_domains, network_domains, clone_candidates, shared_identifiers, cluster_id. Filtré PUBLIC (privacy). Surfaces : GET /api/intelligence/graph/[domain], tool agent getStoreGraph, tool MCP getStoreGraph.
21.3 Ad Libraries multi-network (TikTok + Google + Pinterest)
Le probe ad-library passe en multi-réseau. Adapters Apify par réseau (ad-library/providers/{tiktok,google,pinterest}.ts + helper apify-network.ts) activés quand APIFY_<NET>_ADS_ACTOR_ID est posé. searchAllNetworks fan-out + merge. Nouveau champ canonique ads.networks_detail (par réseau : active_creatives, spend, format, landing pages). Meta reste toujours actif ; les autres dégradent gracieusement.
21.4 IA stratégique (gap I) + sentiment avis (gap D)
- Teardown : service
teardown.ts(generateStoreTeardown) — synthèse Sonnet ancrée sur le record canonical → pourquoi ça scale, faiblesses, opportunités, nouveaux angles/marchés/offres. Tool agentanalyzeStoreStrategy. - Sentiment :
inference/review-sentiment.ts(Haiku) — mine un échantillon de corps d'avis (capturé parreviews_vendor, jamais persisté — PII) →reviews.sentiment_score,top_objections,top_motivations,hidden_usps(L4). Tourne sur full/competitor uniquement (le tick quotidien quick n'appelle pasreviews_vendor→ zéro coût LLM en routine).
21.5 Bilan
- Sections canoniques : 10 → 12 (
seo,graph) + 2 étendues (reviews+sentiment,ads+networks_detail) - Probes : +1 (
seo), meta-ad-library → multi-network - Modèles Prisma : +1 (
StoreSignalIndex) - Tools agents : 6 → 8 (
getStoreGraph,analyzeStoreStrategy) - Tools MCP : 5 → 6 (
getStoreGraph) - Routes API : +1 (
/api/intelligence/graph/[domain]) - Env : +5 (
AHREFS_API_KEY,DATAFORSEO_API_KEY,APIFY_{TIKTOK,GOOGLE,PINTEREST}_ADS_ACTOR_ID)
Reste user / procurement (pas code)
- Ahrefs / DataForSEO contracts (#21.1) : adapters prêts, env vars déclarées
- Apify actors TikTok / Google / Pinterest (#21.3) : adapters prêts, actor ids à poser côté ops
Suite — Prediction Engine
Le moteur d'observation est complet. La suite (moteur de prédiction : breakout/saturation, forecast calibré, opportunity scorer, market clusters, supplier intel, backtesting) est spécifiée dans intelligence-prediction-roadmap.md, c'est le "+8 ans d'avance".
22. Ce que la collecte atteint réellement (couverture dérivée)
Une section canonique remplie n'est pas une section lue. Le §21.1 décrit
dix-neuf feuilles seo collectées à chaque scan ; jusqu'en août 2026,
quinze d'entre elles n'atteignaient aucun humain : ni écran, ni rapport,
ni API. Personne ne l'avait vu parce que rien ne le mesurait.
22.1 Le rapport pnpm intel:coverage
scripts/intelligence-coverage.mjs répond, pour chacun des champs de
canonical/sections.ts (leur nombre est une sortie du script, jamais un
chiffre écrit ici), à deux questions dérivées du code source :
- Quelque chose le PRODUIT ? Un champ qu'aucune sonde n'écrit est une promesse du schéma que le pipeline ne peut pas tenir : il rend un blanc permanent qui se lit comme une lacune sur le marchand.
- Quelque chose l'AFFICHE ? Un champ collecté à chaque scan et montré nulle part, c'est de la bande passante, du quota provider et du stockage payés pour rien.
L'analyse est statique et refaite à chaque exécution, jamais tenue à la main : un inventaire écrit champ par champ est faux la semaine suivante, et faux en silence. Trois règles portent la mesure, et chacune existe parce que la version sans elle mentait :
| Règle | Ce qu'elle corrige |
|---|---|
| Deux sauts (la projection lit la feuille → un composant nomme la clé projetée) | Une recherche par mot de passe crédite domain comme absent et n'importe quel homonyme comme présent |
Alias de section (const p = record.prediction … p?.stage) | Six champs prediction classés « n'atteint personne » alors que /api/intelligence/predict/[domain] les sert tous |
Modules de vue (*/project.ts, *-projection.ts) rattachés à la surface qui les importe | Les mêmes champs reclassés en « plomberie », ce qui est faux dans l'autre sens |
hub-projection.ts est délibérément exclu de la dernière règle : le hub
est déjà modélisé par les deux sauts, et l'y ajouter recréditerait chaque
feuille que la projection touche (57 → 81 champs « visibles » en une
ligne, sans qu'un pixel change).
Deux cliquets (--check, en CI et au pre-push) : le nombre de champs sans
producteur et le nombre de champs orphelins ne peuvent que descendre.
22.2 L'onglet SEO
GET /api/intelligence/seo/[domain] + l'onglet SEO du panneau de détail
du hub rendent les dix-neuf feuilles : autorité hors-page, on-page,
profondeur de contenu, mots-clés, top pages. Servi à la demande plutôt que
plié dans /hub/stores : la section pèse ~2 Ko par boutique et seul le
détail la lit, donc la mettre sur la liste coûterait 100 Ko au premier
rendu du hub pour des données qu'aucun écran de cette page n'affiche.
Visibilité PUBLIC uniquement, comme predict / graph / inspect : le
on-page EST la marque (son title, son h1, ses URL), il n'y a rien à servir
d'une boutique anonymisée une fois retiré ce qui l'identifie.
22.3 Le blanc du hors-page dit à qui il appartient
Sans clé Ahrefs / Semrush / DataForSEO, la sonde n'émettait rien pour
backlinks, domaines référents, trafic organique, domain rating et
mots-clés de classement : le record gardait le not_observed du template,
et toute surface le lisait comme « on a regardé cette boutique et elle n'a
pas de backlinks ». Notre credential manquante, énoncée comme un fait sur
le marchand.
Depuis probe:seo@0.2.0 :
| État | Raison émise | Ce que l'écran dit |
|---|---|---|
| Aucune clé posée | no_oauth | « Aucun fournisseur SEO connecté » — réglage plateforme, pas un fait sur la boutique |
| Clé posée, provider muet | not_observed | « Le fournisseur n'a rien renvoyé » — les boutiques petites ou récentes manquent des index de backlinks |
| Clé posée, métrique absente de la réponse | not_observed | Blanc sur cette métrique seule, le bloc reste répondu |
Même distinction côté on-page, mais sans nouveau vocabulaire :
word_count_home / internal_link_count / indexable sont émis dès que
la page d'accueil a été parsée, quoi que la boutique déclare. Le champ
scanned de la projection en dérive, et sépare « la boutique ne déclare
pas de <title> » (un constat) de « on n'a jamais lu cette vitrine » (le
nôtre).
22.4 Vélocité d'avis et source de trafic
Six feuilles reviews et une feuille traffic étaient dans le même cas
que le bloc SEO : écrites à chaque scan, lues par personne.
last_review_at + recent_count_30d sont la moitié manquante de
l'histoire des avis. 3 000 avis dont le dernier date de dix-huit mois et
3 000 avis qui progressent de 40 par mois sont deux commerces opposés, et
la carte n'affichait que le total. C'est aussi la colonne que l'outil de référence
met en tête de sa vue avis, parce qu'elle bouge à la semaine là où le
total ne bouge presque pas.
photo_ratio, response_rate, verified_buyer_ratio s'affichent
champ par champ, jamais à zéro par défaut : chacun vient du flux
public du vendeur actif et chacun peut manquer seul (Loox publie les
photos et pas les réponses, Trustpilot l'inverse). Un ?? 0 écrirait
« 0 % avec réponse » sur un marchand qui répond à tout via un vendeur qui
ne le publie pas.
sentiment_score et sentiment_sample_size voyagent ensemble ou pas du
tout (reviewSentiment, testé). Le score est une lecture LLM d'un
ÉCHANTILLON : +0,7 sur quatre avis et +0,7 sur huit cents ne sont pas la
même affirmation, et le pipeline produit volontiers le premier sur une
boutique dont le flux vendeur n'a presque rien donné. Le score seul
laisserait prendre l'un pour l'autre.
traffic.top_source était projeté dans HubStore depuis l'adaptateur
provider sans qu'aucun composant le nomme. Un nombre de visites sans canal
ne sépare pas une marque qui loue son trafic d'une marque qui le possède,
et c'est la première chose qu'on regarde en ouvrant un concurrent. La part
payante s'affiche à côté du canal, jamais seule : une part sans le canal
qui la porte ne veut rien dire.
22.5 Registrar, thème, CDN — et le champ qu'on n'affiche pas
identity.domain_registrar était projeté dans HubStore depuis la sonde
d'enregistrement et nommé par aucun composant : le bandeau portait la date
de création et laissait tomber la moitié qui dit quel genre de boutique
a acheté le domaine (registrar Shopify = clé en main, registrar
spécialisé = marque migrée).
Le chip thème porte désormais theme_schema_version (la semver de la
base, donc le signal « thème périmé ») et theme_role seulement quand il
ne vaut pas main : une vitrine servie par un thème unpublished est
soit une migration en cours soit une erreur, alors qu'écrire « main » sur
chaque boutique n'est que du bruit. stack.cdn descend dans le panneau
stack, sous ce qui tourne sur la boutique.
stack.theme_version reste volontairement dans la liste des orphelins.
Ce n'est pas une version : c'est le compteur de republication de
Shopify (/t/<N>/assets/). Sa valeur brute ne dit rien à un lecteur et
serait lue comme un numéro de version ; c'est son mouvement entre deux
scans qui est le signal (le marchand a édité son thème), et cette
comparaison n'existe pas encore. L'afficher pour vider la liste
reviendrait à poser à l'écran un nombre qui ne veut rien dire, ce que ce
document passe son temps à interdire ailleurs.
22.6 Le détecteur avait un troisième angle mort
catalog.product_snapshot était rangé en « collecté, montré nulle part,
utilisé par rien ». Faux : catalog-delta-watcher le lit, mais il va
chercher le record lui-même via un select: { record: true } Prisma, au
lieu de le recevoir en paramètre. La règle readsPriorPass n'était ancrée
que sur existingRecord.
C'est la même faute que pour prediction : le rapport aurait envoyé
quelqu'un supprimer le blob, et ressuscité le bug que son propre
commentaire consigne (sans cette base, chaque produit hors de la fenêtre
de diff est re-signalé product_added à chaque scan).
La règle accepte maintenant les deux formes. Sûre parce qu'une sonde
n'écrit jamais à travers une variable nommée record : ses émissions
passent par canonical.<section> ou un littéral. Vérifié sur tous les
fichiers de probes/ avant d'écrire la règle.
22.7 Deux champs dont le NOM ment, surfacés sous leur vrai nom
Deux feuilles canoniques portent un nom qui promet autre chose que ce qu'elles contiennent. Les câbler sous le nom du champ aurait produit un mensonge à l'écran ; elles sont donc projetées sous le nom de ce qu'elles comptent, avec la raison écrite au point de projection.
| Feuille | Ce que le nom promet | Ce qu'elle contient | Surfacée comme |
|---|---|---|---|
email.sequences | des flows détectés (Welcome, Panier abandonné…) | la liste des vendeurs lifecycle (klaviyo, omnisend) — l'en-tête de la sonde le dit | « Plateforme e-mail » |
ads.hook_clusters | des clusters de hooks créatifs | result.pages.length, soit le nombre de Pages annonceur distinctes — l'en-tête de la sonde parle d'un « distinct page-name proxy » | « Pages annonceur », et seulement au-dessus de 1 |
Le vrai clustering créatif existe ailleurs : ads.creative_clusters.
ads.hook_clusters n'est affiché qu'à partir de 2 : tout annonceur a au
moins une Page, donc « 1 page » est un fait sur la publicité en général,
pas sur cette marque. Deux Pages ou plus, c'est une opération multi-Pages,
et ça se dit.
22.8 L'onglet Emails n'est plus réservé à la démo
Il rendait « indexation en cours » sur toute boutique réelle, alors que la sonde e-mail lit à chaque scan le vendeur lifecycle, l'offre du popup et le formulaire SMS depuis la vitrine. Ces trois-là s'affichent désormais pour une boutique réelle ; seule la liste des e-mails capturés garde son état vide, parce qu'elle n'a effectivement aucun producteur.
Le SMS ne s'affiche que dans le cas positif : la sonde le pose quand
elle trouve un formulaire d'opt-in, et ne pas en trouver n'est pas une
preuve d'absence (les apps SMS dédiées s'injectent au clic). Quant à
email.emails_per_week, il reste dans la liste « déclaré, jamais
produit » : aucune carte de cadence ne peut être honnête sur une boutique
réelle tant que rien ne l'écrit.
22.9 Les trois derniers, et pourquoi ils restent
Le rapport descend de 44 à 3. Ces trois-là ne sont pas des écrans manquants : ce sont trois décisions, et les afficher pour amener le compteur à zéro reviendrait à poser à l'écran quelque chose qu'un lecteur ne peut pas utiliser.
| Feuille | Pourquoi elle n'est pas affichée |
|---|---|
stack.theme_version | Compteur de republication Shopify (/t/<N>/assets/), pas une version. Seul son mouvement entre deux scans dit quelque chose (le marchand a édité son thème) et cette comparaison n'existe pas encore. Sa valeur brute serait lue comme un numéro de version. |
email.niche_benchmarks | Un fait sur une niche, pas sur la boutique. Sa propre doc dit qu'il existe pour permettre aux agents de situer un marchand face au secteur. L'afficher sur la fiche d'un concurrent mettrait un chiffre d'industrie sous le nom d'une marque. |
commerce.price_history | L'en-tête de la sonde le décrit comme une queue glissante conservée « so agents can narrate without a separate query ». Le graphique de prix, lui, lit directement la table IntelligencePriceObservation. |
Les deux derniers ont un consommateur naturel qui n'est pas le Spy : les agents. C'est un chantier d'outillage agent, pas un panneau de plus.
22.10 Le reste du lot
| Feuille | Surface |
|---|---|
commerce.shipping_zones + delivery_days_p50 | Carte « Livraison ». Où un concurrent expédie décide s'il vous concurrence tout court ; une promesse à 3 jours et une à 21 jours ne sont pas la même offre. La carte n'apparaît que si la page de politique a rendu au moins l'un des deux : une politique illisible n'est pas « n'expédie nulle part ». |
catalog.catalog_value_eur + last_change_at | À côté du nombre de produits qu'ils qualifient. 96 produits à 18 € et 96 à 180 € ne sont pas la même boutique, et un catalogue immobile depuis six mois non plus. |
identity.screenshot_at | Date la capture, et seulement quand la tuile montre notre capture : le repli est un og:image puis un favicon, que ce champ ne date pas. Une vitrine change chaque semaine ; une image de quatre mois affichée sans date affirme « voilà à quoi ressemble la boutique ». |
identity.funding_campaigns | Chip cliquable à côté de la date de création qu'elle explique. Détection seule : on lie la campagne, on ne cite pas un montant collecté qu'on n'a jamais lu. Vidé pour les boutiques ANONYMISÉES, une URL de campagne nomme la marque aussi sûrement qu'un domaine. |
Une note sur projectFundingCampaigns : la feuille est un tableau
d'objets, pas de chaînes. leafArr filtre sur les chaînes et aurait
donc renvoyé un tableau vide en silence — c'est exactement comme ça qu'un
champ peut avoir l'air « câblé » et ne jamais rien afficher.
22.11 A/B est deux mots du marchand, pas une expression
valisedemo nomme ses types de produits chèche/besace, pad/banane,
écharpe/sacoche, veste/sac. Chacun est deux produits, et chaque
moitié est un mot de bagagerie ou de vêtement que la taxonomie connaît.
En une seule chaîne, aucun ne matchait quoi que ce soit : 42 des 51
produits ne disaient rien de ce que vend la boutique.
expandSlashPhrases (sonde catalogue 0.15.0) ajoute les moitiés à
côté du tout, jamais à la place : une expression slashée qui EST un vrai
nom garde sa chance de matcher entière, et l'opérateur relit les mots du
marchand tels quels dans le rapport de scan. Chaque moitié hérite du
compte du tout, ce qui est la lecture honnête : quinze produits portent
chèche/besace, donc quinze portent chèche et quinze portent besace.
mergePhrases replie ensuite une moitié qui existe aussi seule.
Les garde-fous laissent entier tout slash qui ne sépare rien : une partie
de moins de trois caractères (s/o, w/), purement numérique (1/2,
24/7, 50/50), une URL, ou plus de trois parties.
22.12 Une racine non corroborée ne peut plus opposer son veto
Le split seul ne suffisait pas, et la mesure le dit mieux qu'un raisonnement. Sur le catalogue réel :
| Avant split | Après split | |
|---|---|---|
aa Vêtements et accessoires | 6 · 2 mots | 20 · 3 mots (vestes, sacoches, écharpes) |
lb Bagagerie | 4 · 1 mot | 15 · 1 mot (besaces) |
fb Alimentation | 4 · 1 mot | 14 · 1 mot (bananes, le fruit) |
| Verdict | low_share | tie |
aa domine et est corroborée trois fois ; lb la bloquait sur un seul
type de produit. Or une racine portée par un seul mot est exactement celle
que le classifieur refuse de publier seule (MIN_DISTINCT_PHRASES).
Appliquer cette conviction dans un sens et pas dans l'autre laissait une
racine invendable en bloquer une défendable.
La marge se mesure désormais contre le premier concurrent corroboré. C'est étroit par construction : la racine de tête n'est pas touchée et doit toujours passer corroboration, part et confiance ; et une racine non corroborée qui domine au score reste en tête, où la porte la refuse.
Résultat sur valisedemo : Vêtements et accessoires > Sacs à main, portefeuilles et étuis > Sacs à main > **Sacoches** — un mot que le
marchand a écrit lui-même. Et banane ne peut pas faire gagner le fruit.
22.13 Le client Prisma ne peut plus faire échouer un typecheck
pnpm typecheck lance prisma generate avant tsc. Un client généré
périmé signalait comme erreurs de type des valeurs d'enum ajoutées au
schéma par un autre travail (constaté sur SubscriptionStatus, dans
webhooks.ts, un fichier sans rapport avec le changement en cours).
Le coût est de ~2 s sur un typecheck qui en prend 60 à 90 ; le bénéfice
est qu'un git pull ne peut plus produire une erreur qui n'a rien à voir
avec le code qu'on est en train d'écrire.
23. agentic — la dimension que les concurrents ne peuvent pas avoir
Quatorzième section canonique, six feuilles, toutes L1 observées, et
la sonde agentic_readiness qui les produit ne coûte rien : trois GET
non authentifiés sur des documents que la boutique publie elle-même.
23.1 Le fait
Chaque storefront Shopify sert {origin}/.well-known/ucp. La
documentation de Shopify est explicite sur le point qui compte : « You
can use this document to confirm the merchant supports carts before
calling create_cart ». Cette phrase n'a de sens que si l'ensemble de
capacités varie d'un marchand à l'autre. Ce n'est donc pas une
constante de plateforme, c'est un signal discriminant — et il décrit une
dimension qui n'existait pas avant 2026, qu'aucun outil du marché ne
mesurent.
Deux surfaces adjacentes viennent de la même fonctionnalité de thème et
coûtent un GET chacune : /agents.md et /llms.txt.
| Feuille | Source |
|---|---|
agentic.ucp_discoverable | le GET a-t-il rendu un document UCP valide |
agentic.ucp_versions | ucp.supported_versions, plus récente d'abord |
agentic.capabilities | clés de ucp.capabilities |
agentic.mcp_endpoint | services["dev.ucp.shopping"][].endpoint |
agentic.agents_md | /agents.md présent |
agentic.llms_txt | /llms.txt présent |
23.2 Ce que la sonde n'a pas le droit de dire
« Pas prêt » est une affirmation sur le marchand. Un timeout, un 403, un
échec de proxy et une page d'erreur HTML sont des affirmations sur
nous. Ils restent not_observed et ne deviennent jamais une lecture
négative — la même règle qui fait rendre null et pas [] à
readPolicyHits, appliquée à une autre porte.
Trois distinctions portent tout le reste :
- Un 404 EST une observation. La boutique a répondu, et elle ne sert pas ce document. Seule une requête qui jette laisse un blanc.
- Un bloc
capabilitiesvide n'est pas un bloc absent. Le premier est la réponse du marchand (« aucune »), le second n'a jamais existé.readAgenticDocumentles sépare parhasCapabilityBlock, et la sonde n'émet le champ que dans le premier cas. - Un 200 ne prouve rien sur
/agents.md. Une boutique Shopify répond à un chemin inconnu par une 404 thématisée en statut 200 plus souvent qu'autrement.looksLikeMarkdownDocumentrefuse tout ce qui ouvre sur du HTML, sans quoi toute boutique ayant un template 404 serait créditée de publier les deux documents.
23.3 Le classement
agentic_score (0-4, un point par surface publiée) est dérivé à la
projection, pas stocké : c'est un rang de présentation sur quatre
booléens observés, et le stocker en ferait un fait autonome qui peut
diverger des quatre qu'il résume.
Il vaut null — jamais 0 — quand rien n'a été observé. La
distinction est tout l'intérêt : une boutique qu'on n'a pas pu lire doit
se classer après toute boutique lue, pas à côté de celles qui ne publient
réellement rien. C'est la règle que byMeasuredDesc applique déjà sur
les pubs et la croissance ; c'est le troisième champ auquel elle
s'appliquait.
Le tri s'appelle « Most agent-ready » dans le Store Spy.
intelligence/0136 demandait « un classement public par catégorie » :
les URLs /intelligence/<category> redirigent depuis qu'elles
dupliquaient le browse/filter/sort du Hub, donc le classement vit là où
le classement vit maintenant. Le diagnostic côté marchand (« vos
concurrents annoncent cart + checkout, vous annoncez catalog
seul ») appartient au synthétiseur d'action-plan, un autre pilier : suivi
séparément.
23.4 Robots
.well-known est réservé aux métadonnées lisibles par machine (RFC 8615)
et se lit directement. /agents.md et /llms.txt sont des chemins de
contenu ordinaires qu'un marchand peut interdire : ils passent par
getRobotsRules. Un chemin interdit n'est pas lu, et son absence de
réponse est alors notre choix — donc not_observed, jamais « il n'en
publie pas ».
23.5 La moitié marchande (commerce-systems/0147)
La mesure ci-dessus décrit une boutique espionnée. Le marchand connecté, lui, voulait la phrase inverse :
vos concurrents annoncent
cart+checkout, vous annoncezcatalogseul
Elle est livrée comme une constatation du Strategic Action Plan
(features/action-plan/agentic-gap.ts, pilier commerce-systems), sous
le pilier aeo — /llms.txt est déjà un AeoAuditKind, et le CTA tombe
sur le même système. La comparaison est pure et testée ; le
synthétiseur ne fait que l'alimenter.
Le jeu de comparaison est fait de ce qui est déjà indexé : aucune
sonde, aucun crawl, aucun appel d'embedding sur un rendu de page. Deux
lectures : le Store Graph (network + clone_candidates) et le cluster
de niche du marchand (MarketCluster.top_domains). same_owner_domains
en est exclu — ce sont les AUTRES boutiques du marchand, et se comparer à
soi-même se lirait comme une constatation concurrentielle en ne mesurant
rien. Les concurrents sont comptés, jamais nommés : le nombre informe
la décision, le nom republierait un choix de visibilité qui n'appartient
pas à cette surface.
Quatre silences, et chacun est un cas où parler reviendrait à décrire NOTRE couverture comme un fait sur le marchand :
| Lecture | Sortie |
|---|---|
ucp_discoverable jamais observé | silence — l'absence est la nôtre |
| aucun concurrent observé | silence — une comparaison sans terme |
document lu, aucun bloc capabilities | silence — « déclare aucune » et « déclare ailleurs » sont indiscernables |
| le marchand est déjà en avance | silence — rien à dire |
Et le cas inverse, qui compte autant : ucp_discoverable === false — la
boutique a répondu et ne sert aucun document — a un ensemble déclaré
vide et connu. Il se compare, et c'est la lecture la plus utile de la
fonctionnalité. Un silence trop zélé l'aurait tuée.
23.6 Le défaut que cette moitié a révélé
agentic ne figurait pas dans StoreCanonicalRecordSchema. z.object
retire ce qu'il ne déclare pas : la section était écrite (l'upsert
persiste input.record, pas parsed.data) et effacée à chaque
lecture par getCanonicalRecord. Comme le deep scan réconcilie contre
cette lecture, une passe où la sonde skippait pour fraîcheur
(cost.skipIfFresh) réécrivait la ligne sans la section : une vraie
perte de données, silencieuse, et invisible sur le Spy — qui projette
depuis la colonne JSONB brute, sans Zod.
Aucun des tests de schema-roundtrip.test.ts ne pouvait le voir : ils
vérifiaient tous success === true, et un strip réussit. Le garde ajouté
parcourt SECTION_KEYS et exige que chaque section ressorte du
validateur — dérivé du template, donc une section ajoutée sans sa ligne
dans le schéma échoue sur le commit qui l'ajoute.
24. Les dépenses, les secrets et les affirmations (audit 2026-09-03, lot P2/P3)
intelligence/0317. Sept constats de l'audit du 2026-09-03 dont le point
commun n'est pas la sévérité mais la forme : dans chacun, un
commentaire ou une réponse d'API décrivait une propriété que le code
n'implémentait pas, et personne ne pouvait s'en apercevoir depuis
l'extérieur.
24.1 Le second client Firecrawl passe par le disjoncteur
src/lib/scraper/ construit son propre FirecrawlApp pour les outils
@Atlas et le bot WhatsApp. Il ignorait entièrement lib/provider-budget,
que lib/fetch-providers/firecrawl.ts consulte à chaque appel. Compte
capé (402) : la chaîne de scan s'arrêtait, ce client-ci continuait, un
tour de chat après l'autre, et rachetait le même refus. Aucun appel
n'avait de borne non plus.
Désormais : isBudgetTripped("firecrawl") avant chaque appel facturé,
tripBudget sur un 402 lu depuis err.status (le SDK lève un
FirecrawlSdkError qui porte le statut amont), et un timeout par appel
sur le scrape. Le refus lève au lieu de rendre un document vide :
chaque appelant enveloppe déjà l'appel dans un try/catch qui le
traduit en { success: false }, alors qu'un document vide arrive au
modèle comme une page sans contenu, ce qui le fait réessayer.
scrapeStoreMeta ne passe plus par Firecrawl du tout. Il lisait la
homepage d'un store à chaque création de store et chaque détection
d'organisation pour en extraire un <title> par regex, en payant un
rendu, et en le payant pour le mauvais HTML : le format html de
Firecrawl est son extraction nettoyée, qui retire <meta> et <link>
(cf. le commentaire de fetch-providers/firecrawl.ts), donc les regex
og:* ne matchaient probablement rien. Il lit maintenant par
fetchWithFallback(base, { skipFallbacks: true }) : le barreau direct,
gratuit, jamais d'escalade vers un rendu payant, et un validateScanUrl
d'abord, parce que déplacer le fetch de l'infrastructure de Firecrawl
vers la nôtre transforme un hostname fourni par l'appelant en surface
SSRF.
24.2 /api/intelligence/og-image n'est plus un cache d'images ouvert
L'en-tête promettait « proxies ONLY the store's own og:image ». Le
handler lisait ?url= et servait n'importe quelle URL https qui
passait le garde SSRF et répondait en image/*. Le garde SSRF traite
l'interne ; il ne dit rien du coût. À 120 req/min/IP et 8 Mo par image,
avec un cache CDN de 24 h, c'était un hébergement d'images gratuit sur
notre bande passante.
L'URL doit maintenant être signée par nous. ?url= seul est refusé ;
?sig= doit porter un HMAC émis par signOgImageUrl
(lib/security/og-image-token.ts), et hub-projection ne signe qu'un
actif dont l'hôte est le domaine du store lui-même ou un CDN de
storefront Shopify. La vérification a lieu avant le fetch, donc une
URL non signée ne coûte même pas une requête sortante.
Une liste d'hôtes autorisés avait été écrite d'abord, puis abandonnée à
la fusion : elle laissait passer tout cdn.shopify.com, c'est-à-dire
n'importe quelle image téléversée sur n'importe quelle boutique d'essai.
Une capacité signée n'a pas ce bord. Il échoue fermé : sans clé de
signature, le proxy ne sert rien plutôt que tout.
24.3 Les tokens provider voyagent en en-tête
provider-health.ts:17 énonçait déjà l'invariant pour lui-même :
« Tokens travel in Authorization headers, never in a URL, so a thrown
fetch error can never echo one back. » Quatre modules faisaient
l'inverse avec APIFY_TOKEN et META_AD_LIBRARY_TOKEN. Une URL est la
chaîne la plus recopiée d'une requête : log du proxy sortant, attribut
OpenTelemetry http.url, texte de l'erreur fetch. Les deux tokens
commandent du travail payant.
src/services/discovery/apify-client.ts est désormais le seul endroit
où une requête Apify se construit, et il refuse un token dans le
chemin plutôt que de le retirer en silence. meta-graph.ts passe son
access_token en en-tête. Une exception reste, déclarée :
meta-token-health.ts interroge debug_token, dont le paramètre
input_token est le sujet de la requête et n'existe nulle part
ailleurs ; ici sujet et credential sont la même valeur.
Le garde est comportemental (src/test/provider-secrets-never-in-url.test.ts) :
chaque client est réellement exécuté contre un fetch bouchonné avec
des valeurs sentinelles, et l'assertion lit ce qui a été demandé. Un
regex sur les sources ne verrait pas un token ajouté par un helper, et
se déclencherait sur un lien de désabonnement que personne ne fetch.
24.4 Le 404 de inspect envoie vers le bon pipeline
/api/intelligence/inspect/[tool] répondait à un domaine inconnu avec
scan_url: "/api/tracking/scan". C'est un autre pipeline : l'audit
tracking/pixels, gated Turnstile et authentifié, qui n'écrit jamais
StoreIntelligence. Un agent qui suivait l'indication lançait un audit,
réinterrogeait inspect, et retrouvait le même 404. Une boucle sans
sortie, publiée comme contrat d'API.
La réponse nomme maintenant POST /api/intelligence/hub/scan, avec la
méthode, le corps et le plafond (8 req/h/IP), et
src/test/intelligence-scan-entrypoint.test.ts résout le littéral
vers un route.ts réel plutôt que de le comparer à une chaîne.
Les liens des pages publiques vers /scan/tracking restent : ils
demandent la machinerie de deep-link du Hub, qui appartient à un autre
pilier (intelligence/0386).
24.5 Les clés bei_ sont journalisées, plafonnées, limitées
Émission et révocation passaient du contrôle d'appartenance à l'écriture
Prisma sans AuditLog, sans rateLimit, sans compter les clés actives.
Une clé fuitée puis révoquée ne laissait aucune trace de qui l'avait
émise. Et comme /api/mcp/intelligence bucketise sur caller.keyId,
frapper des clés était le moyen le moins cher de multiplier son quota.
Ajoutés : deux lignes AuditLog (intelligence.api_key.issued /
.revoked, la seconde après le garde count === 0), un plafond de 20
clés actives par organisation, et deux buckets de rate-limit
distincts. La révocation a le plus large des deux, délibérément : elle
est idempotente et réduit un privilège, elle ne doit jamais être ce qui
manque de budget pendant un incident.
La correction de fond est livrée (security-identity/0387) : les deux
surfaces Intelligence — /api/mcp/intelligence et
guardIntelligenceRoute — bucketisent sur caller.orgId. Émettre une
deuxième clé n'achète plus un deuxième quota, donc le plafond de 20 clés
ci-dessus n'est plus qu'une mesure d'hygiène : il borne la taille d'une
fuite, pas un débit. IntelligenceApiKey.orgId est NOT NULL, donc aucune
clé déjà émise n'a eu à être réémise.
24.6 Deux surfaces qui présentaient une fenêtre comme un graphe
/api/intelligence/nl-search déclarait un filtre niche_keyword, le
faisait extraire de chaque requête par un modèle, puis ne le lisait
nulle part ; min_layer était compilé en égalité, donc « au moins
L2 » excluait les lignes L1, les plus sûres ; et l'ensemble candidat
était les limit * 5 fiches les plus récentes, 50 par défaut.
Le mot-clé est maintenant lu : cherché dans la niche du catalogue, le
nom du store, les catégories et les titres des meilleures ventes, avec
une seconde aiguille résolue par canonical/niche-vocabulary.ts (le
vocabulaire vertical, sorti de la sonde catalog) pour le cas que la
première ne peut pas couvrir — la fiche stocke Jewelry, l'utilisateur
tape bijoux. Un mot que le vocabulaire ignore n'ajoute pas d'aiguille
et n'élargit donc jamais le filtre. min_layer est un plancher de
qualité. La fenêtre est une constante nommée (2000), et chaque réponse
porte scanned et window_truncated.
/api/intelligence/opportunities et /intelligence/radar classent la
même façon (fenêtre de fraîcheur, tri en JS) parce que
opportunity_score vit dans le JSONB et qu'il n'y a rien à indexer.
L'en-tête de la route promettait « the highest-opportunity PUBLIC stores
BoostEcom tracks » : il décrit maintenant la fenêtre, et la réponse porte
scanned et window_truncated — les mêmes deux champs que nl-search,
sur la même fenêtre de 2000, pour que la page et l'API ne puissent pas
diverger sur le sens de « top ». La correction qui supprime la fenêtre
(trois colonnes indexées écrites au scan) est intelligence/0384 :
elle touche prisma/schema.prisma, donc elle a son propre item.
24.7 /api/intelligence/top est cacheable
Le catalogue de découverte est dérivé d'une constante de code, et
répondait sans Cache-Control, donc en invoquant une fonction à chaque
poll d'agent. Il porte maintenant
public, s-maxage=3600, stale-while-revalidate=86400, la forme de
/api/pricing/models : pas de max-age, parce que le commit
9127f3d69 a dû corriger le lien web de chaque catégorie et qu'une
heure de cache navigateur aurait continué à servir le lien mort après le
déploiement qui le réparait.
24.8 Ce que traffic-provider dit de lui-même
Le bloc « Legal posture » affirmait lire « the same public HTML a
logged-out browser sees ». 900 lignes plus bas, la sonde forge un
Referer, force requireRenderedHtml et pose stealth: true pour
franchir le mur anti-bot de SimilarWeb, en le documentant. L'en-tête dit
maintenant ce que le code fait. La question de fond, continuer ou non à
franchir le mur d'une source commerciale, est une décision opérateur
avec une dimension contractuelle : intelligence/0388.
25. Le plancher de dépense, et la ligne que personne ne rescannait (2026-09-06)
Deux constats du lot P2 des audits des 3 et 5 septembre, tranchés ici parce que les deux décidaient d'une dépense sans qu'aucun chiffre ne soit écrit nulle part.
25.1 Un scan on-demand anonyme coûtait un rendu, et rien ne le comptait
Le fait, mesuré sur le code du 6 septembre :
| Chemin | Appels rendus | Crédits Firecrawl |
|---|---|---|
Page d'accueil partagée (app_detector, pixel_detector, analytics, reviews_aggregation, social_handle_extractor, popup_detector, emails) | 1 | 1 |
traffic_provider (SimilarWeb, stealth) | 1 | 1, 5 sur mur anti-bot, et SimilarWeb nous mure |
reviews_vendor (Trustpilot, stealth) | 1 | 1 à 5 |
storefront_screenshot (appel direct api.firecrawl.dev) | 1 | 1 (estimatedUsd: 0.01) |
social_metrics_fetcher (4 surfaces stealth, IG/FB refusés par politique avant l'appel) | ≤ 4 | 1 à 5 chacun |
meta_ad_library_fetcher | 0 | source gratuite d'abord, acteur Apify estimatedUsd: 0.1 si vide |
Soit ~8 à 15 crédits pour un scan complet, et exactement 1 pour
un scan dégradé : les sept sondes de la première ligne demandent
requireRenderedHtml, fetchWithFallback jette une réponse direct
valide dès qu'un provider de rendu est configuré, et
cacheKeyFor(url, rendered, stealth) fusionne leurs sept requêtes en un
seul appel.
Ce plancher de 1 crédit était borné par une seule chose : la limite de
la route, 8 scans/heure/IP. Soit 192 rendus par jour et par adresse,
sans plafond global, sur un endpoint qui ne demande aucun compte. À
~3 300 crédits/jour (ce qu'autorise un mois à 100k crédits),
dix-huit adresses en régime continu épuisent la journée. Et la
réservation quotidienne existante (INTELLIGENCE_ON_DEMAND_PAID_SCANS_PER_DAY,
100 par défaut) ne bornait que le surplus : une fois atteinte, chaque
recherche continuait de coûter son crédit.
Amplificateur que l'item n'avait pas vu : le fast path de la route exige
monthly_visits != null || first_archive_at != null. En régime dégradé
traffic_provider est exclu, donc monthly_visits ne se remplit jamais,
et la complétude ne tient plus qu'à first_archive_at (Wayback). Une
boutique jeune, précisément ce qu'un utilisateur du Spy cherche, n'a
pas d'archive : sa fiche ne devient jamais « complète », le fast path ne
s'enclenche jamais, et chaque recherche du même domaine repaie un
rendu, indéfiniment.
Décision : option 1 de l'item. Une seconde réservation quotidienne,
reserveOnDemandRenderScan, sur son propre compteur
(intelligence:on-demand:render-scans). Refusée, le scan tourne dans
withoutRenderSpend : mêmes sondes, probeFetch épinglé au provider
direct, requireRenderedHtml et stealth retirés. Le visiteur garde
une réponse (plus maigre là où une vitrine a besoin de JS), la dépense
s'arrête.
Trois choix à l'intérieur de cette décision, et pourquoi :
- Pas de seconde variable d'environnement. Le plafond de rendus est
INTELLIGENCE_ON_DEMAND_PAID_SCANS_PER_DAY × 10. Deux leviers indépendants peuvent se contredire, et la contradiction qui compte est=0, documenté comme « garde la recherche disponible, ne dépense rien » : avec un plafond de rendus autonome, ce réglage aurait continué d'acheter un rendu par recherche. Dérivé, « ne dépense rien » est exact. Au défaut, cela fait ≤ 100 scans complets + ≤ 900 scans à un rendu, soit ~1,7 à 2,4k crédits/jour. - L'enforcement est dans
probeFetch, pas dans chaque sonde.requireRenderedHtml: trueest écrit dans sept modules ; un drapeau que sept sondes doivent penser à honorer sera oublié par l'une d'elles, et la fuite serait exactement le rendu qu'on borne.probeFetchest la seule porte vers la chaîne de providers, et un test le dérive de l'arbre plutôt que de l'affirmer. AsyncLocalStorageet pas un booléen de module. Deux recherches peuvent être en vol dans le même lambda ; un drapeau partagé laisserait le budget de l'une décider de l'autre.
Ce qui n'a PAS été fait, et pourquoi. L'option 2 (baisser
RATE_LIMIT pour les domaines non indexés) touche le calcul d'identité
IP de la route, réécrit par ailleurs sur security-identity/0366 : deux
branches sur les mêmes lignes est le conflit qu'on évite. Elle reste
ouverte si le plafond dérivé se révèle trop haut.
25.2 Le tier RETRY : la ligne dont le premier scan n'a rien donné
listColdTier exclut maxLayer: { not: null }, HOT exige un storeId,
WARM et spy-full exigent 3 lookups par semaine. Une ligne externe,
vivante, sans aucune couche de données n'était donc sélectionnée par
aucun tier — et elle ne pouvait pas non plus gagner ses lookups,
puisque ne portant aucune donnée elle n'apparaît dans aucun classement
d'où un visiteur pourrait la chercher. Discovery payait pour la trouver,
un scan échouait une fois (timeout, mur anti-bot, 429), et plus rien ne
réessayait jamais.
Décision : option A de l'item (un vrai passage de ré-évaluation),
malgré l'absence du chiffre de production que l'item demandait
(SELECT count(*) … WHERE storeId IS NULL AND isAlive AND maxLayer IS NULL
n'est pas mesurable depuis un clone). La raison est que A est la seule
des deux options dont le coût ne dépend pas de ce chiffre : le lot
quotidien est de 20 lignes quelle que soit la taille du bassin, donc
20 scans quick par jour, point. B (supprimer les lignes) est
destructif et irréversible, et le décider sans compter ce qu'on
supprime n'est pas une décision, c'est un pari.
listRetryTier sélectionne le complément exact de COLD (externe,
PUBLIC, vivant, maxLayer: null), retire les domaines que WARM prend
déjà, et exige que lastScanFail soit nul ou plus vieux que 7 jours. Il
est branché en TÊTE du cron refresh-cold existant (pas de nouveau
créneau : la limite Vercel est à 100 et 59 sont pris), avec 60s de budget
contre 170s pour COLD — en second, un bassin COLD volumineux l'affamerait
tous les jours, ce qui est la forme du défaut qu'il répare.
Deux détails qui décident si le passage tourne vraiment :
- Le tri est
lastScanFail asc NULLS FIRST, explicitement. Postgres trie les nuls en DERNIER sur un ordre ascendant, donc sans la mention les lignes jamais réessayées — celles pour qui ce passage existe — seraient au fond de leur propre file. - La rotation tient à une colonne que le sweep de liveness ne remet
pas à zéro. Ce bassin est plein de domaines vivants et
inscannables : le sweep réécrit
lastScanOketconsecutiveFailuresà chaque passage, donc trier sur l'un des deux aurait rendu les vingt mêmes lignes tous les jours.applyLivenessOutcomen'écritlastScanFailque sur l'échec, et ne l'efface jamais.
Le compteur à surveiller est retry.scansOk dans
cron.intelligence.refresh_cold.done : rapporté à part et non additionné
aux totaux COLD, parce que c'est le seul nombre qui dit si le bassin se
vide.
25.3 Turnstile devant le budget, pas derrière
Le plafond 8/h/IP et les deux réservations quotidiennes bornent un
appelant qui a déjà le droit de scanner. Ils ne distinguent pas un
visiteur d'un script qui tourne les adresses. Depuis
intelligence/2761, quand NEXT_PUBLIC_TURNSTILE_SITE_KEY est posée,
POST /api/intelligence/hub/scan exige un jeton Turnstile d'action
hub-scan avant le seau par IP et avant resolveBareLabel (le
fan-out qui teste une dizaine de TLD). Jeton absent ou rejeté : 403,
aucune sonde, aucun crédit. Sans site key (dev), la route reste
ouverte, même porte que /api/auth/send-otp. Le bouton du hub envoie
le jeton et le réémet après chaque scan : un jeton Turnstile est à
usage unique.
26. Une clé, un lecteur, un verdict (audit 2026-09-03, lot P2/P3 restant)
intelligence/0320 et intelligence/0363. Ce que ce lot corrige n'a
rien de spectaculaire pris ligne à ligne, et c'est le point : chaque
défaut produisait une réponse plausible et bien formée, donc aucun ne
pouvait apparaître comme un bug.
26.1 Une seule normalisation décide de primaryDomain
Quatre implémentations privées de normalizeDomain coexistaient dans le
pilier, avec des règles différentes : liveness.ts gardait www.,
store-meta.ts renvoyait url.hostname (donc www. aussi),
hub/scan/route.ts enchaînait un nettoyage inline puis rappelait
normalizeDomain quatre-vingts lignes plus bas, et
discovery/sources/apify.ts — que l'audit n'avait pas vue — normalisait
sans retirer www. du tout.
Conséquence : checkLiveness("www.acme.com") sondait
https://www.acme.com pendant que la ligne qu'il met à jour est clé
acme.com, et une ligne de dataset Apify lisant www.acme.com créait
une seconde StoreIntelligence au lieu d'un second regard sur la
même boutique.
canonical/identity.ts est désormais la seule implémentation. C'est un
module pur (zéro import), donc « il vit dans services/ » n'a jamais été
une raison d'en écrire une cinquième dans lib/.
src/test/intelligence-pillar-hygiene.test.ts refuse toute nouvelle
déclaration et vérifie la parité www. / port / chemin / query / casse.
Ce qui reste chez apify.ts est ce qui n'est PAS de la normalisation :
rejet des valeurs IP-like et mail-style, re-suffixage d'un handle
myshopify nu. La fonction s'appelle toCandidateDomain, ce qu'elle fait.
26.2 Un inspecteur, deux rendus
/api/intelligence/inspect/[tool] et /intelligence/inspect/[tool]
répondaient à la même question et portaient chacun sa copie du garde de
visibilité, du getCanonicalRecord, de la table surface → section et de
la boucle isPresent. Les deux copies avaient déjà divergé, et dans le
mauvais sens : la page rendait error.message — une chaîne Prisma, nom
de table et de colonne compris — sur une page publique, pendant que la
route disait en commentaire « Don't leak internals to anonymous
callers ».
src/services/discovery/inspect.ts (inspectDomain) porte maintenant la
décision ; les deux appelants portent la présentation. Un échec y est
journalisé et ressort en status: "error" sans message : aucun
appelant ne peut plus en afficher un.
Effet de bord voulu : le not_found de la route ne dit plus POURQUOI.
Un domaine non-PUBLIC répondait « The owner has opted out », un domaine
inconnu « No canonical record yet » — même code, même statut, deux
messages. C'était un oracle : un appelant anonyme distinguait « nous
avons une fiche et son propriétaire l'a délistée » de « nous n'avons
jamais vu cette boutique », ce que le garde de visibilité existe
précisément pour ne pas dire. Les deux cas renvoient la même réponse.
Le comptage de demande (recordIntelligenceLookup) reste une option
explicite : la route JSON compte, la page publique n'a jamais compté, et
en faire un effet de bord silencieux de la lecture aurait changé ce que
les crons de refresh dépensent, piloté par une surface qui n'a jamais
demandé à facturer.
26.3 Le hash IP de l'opt-out : option A, et ce qui manquait vraiment
IntelligenceOptOut.ipHash est écrit à chaque opt-out et lu par aucun
code. intelligence/0363 proposait A (chaîner
INTELLIGENCE_ANON_SECRET || NEXTAUTH_SECRET avant le
repli aléatoire), B (arrêter d'écrire la colonne) ou C (faire porter le
plafond quotidien par la colonne).
A retenu, et la chaîne était déjà là. src/env/server.ts résout
INTELLIGENCE_ANON_SECRET sur les trois noms en || (jamais ?? : un
env non posé sur Vercel arrive en ""). L'unique lecture de
antiSpamSalt() les parcourt donc déjà toutes les trois, et réécrire la
chaîne dans anon-secret.ts aurait été du code mort. Ce qui manquait
n'était pas la ligne, c'était la preuve : anon-secret.test.ts épingle
les deux moitiés — la chaîne || dans le schéma env, et la propriété qui
rend la colonne joignable : le même secret redonne le même sel après un
rechargement de module, c'est-à-dire après un cold start.
B a été écarté parce que la colonne a bien un lecteur, humain : n'importe
qui peut délister n'importe quel domaine ici, sans compte ni preuve, et
grouper les lignes par ipHash sur une fenêtre est la façon dont une
campagne devient visible sans ajouter de table (intelligence/0241). Le
warn seul sort d'un drain de logs au bout de sa rétention, pas les
lignes.
C a été écarté : il ferait dépendre un chemin de retrait RGPD d'une
lecture DB par requête, et le cron prune-history supprime les opt-outs
non vérifiés au bout de 30 jours — ce qui remettrait le plafond à zéro
pour l'adresse concernée.
Le commentaire du schéma ne dit plus « salted hash for anti-spam rate
limiting » : ce n'est pas ce que la colonne fait, les deux plafonds
tournent sur rateLimit clé sur l'adresse brute.
26.4 Une variable d'environnement qui ne fait rien le dit
CLICKHOUSE_URL: les deux branches degetTimeSeriesStore()renvoyaientpostgresStoreet la fonction ne contenait aucun appel au logger, alors que son commentaire promettait « a one-line marker so monitoring can flag the discrepancy ». Elle passait donc le test « consommée » et restait dans l'env-matrix comme un levier. Le marqueur est émis, une fois par processus — le resolver est appelé à chaque point enregistré parrefresh-runner.OPENCORPORATES_API_TOKEN: l'en-tête annonçait un « free tier (no API key required) ». OpenCorporates exigeapi_tokensur chaque requête. Sans la variable, le filet global de la cascadelookup.tsdépensait 5s de budget pour un 401 queif (!res.ok) return []transformait en « aucune entreprise trouvée ». Il court-circuite maintenant et le dit une fois par processus.
26.5 Un audit de posture qui écrivait ses propres verdicts
security.sweep alimente /admin/settings/security et un score sur 100.
Six de ses contrôles passaient le littéral "ok" et ne mesuraient rien.
Le pire, forms.csrf, déclarait sain :
NextAuth issues SameSite=Lax cookies. State-changing routes still require a custom
x-boostecom-csrfheader for cross-site fetch hardening.
x-boostecom-csrf figurait dans cette phrase et dans aucun autre
fichier du dépôt. Aucun middleware ne l'exigeait, aucun helper ne
l'attachait. Le tableau de bord marquait 100 pour un contrôle qui n'avait
jamais existé, et demandait à l'opérateur de « confirmer » un en-tête
introuvable.
Chaque contrôle observe désormais quelque chose (une variable posée, un
export de module, la table SECURITY_HEADERS) ou dit warn en nommant
le manque. forms.zod-validation a été retiré plutôt que maquillé : « tout
handler POST devrait valider son corps » n'est pas observable depuis un
processus, et le README prescrit lui-même de retirer un contrôle périmé
plutôt que de le porter.
src/test/security-sweep-no-constant-status.test.ts refuse un nouveau
littéral "ok" ou "skipped" — les deux statuts qui valent 100. Un
warn littéral reste légal : c'est un aveu qui coûte du score tant que
le trou n'est pas bouché, il ne peut flatter personne.
Enfin, l'en-tête et le README promettaient un cron quotidien qui poste
les régressions critiques. Il n'existe pas, ni dans vercel.json ni sous
src/app/api/cron. Les deux documents le disent, et le test échoue si
quelqu'un ajoute le cron sans les corriger.
Le même défaut est revenu le 2026-09-17 sous une autre forme : deux
contrôles qui mesuraient bien quelque chose, mais pas la posture
écrite. headers.hsts exigeait Cross-Origin-Embedder-Policy, que
csp.ts omet à dessein (COEP casse le Live View Browserbase) — la
remédiation du sweep aurait re-cassé l'inspecteur. Et
storage.token-encryption disait « tokens en clair », critique, dès que
TOKEN_ENCRYPTION_KEY manquait, alors que crypto.ts retombe sur
NEXTAUTH_SECRET, confirmé présent par le même rapport. Le sweep lit
désormais CORP (qui est dans la baseline) et la chaîne de repli complète
(ok / warn / critical selon le maillon présent).
src/test/security-sweep-matches-the-posture.test.ts dérive les deux
vérités de csp.ts et crypto.ts (intelligence/2712).
26.6 Frontières : deux fichiers changent de pilier
src/lib/browser/web-speech-shim.ts(types Web Speech API navigateur) vivait dans le répertoire des providers headless serveur, propriété du pilier intelligence, pour n'être importé que par le chat vocal. Il part soussrc/features/ai/chat/lib/integrations/voice/.src/lib/anon-key.ts(cookie de vote anonyme/roadmap) était attribué à intelligence parownership.jsonalors que ses deux seuls consommateurs sont growth-web. La ligne change de pilier, et le fichier litserverEnv.NODE_ENVau lieu deprocess.env.NODE_ENV— le schéma typé normalise unNODE_ENVabsent en"production", donc les deux lectures divergeaient exactement là où ça compte : le flagSecuredu cookie.
27. IntelligenceWatchlist retiré : StoreTracker devient la seule primitive de suivi (2026-09-11)
Le modèle IntelligenceWatchlist (ligne 12 du tableau ci-dessus) n'a
jamais eu d'écrivain : grep -rn "intelligenceWatchlist" src/ hors
schéma ne rendait que la lecture de /[orgSlug]/intelligence/page.tsx.
Aucun create, upsert ni createMany n'a jamais existé — l'audit du
2026-09-03 l'avait déjà constaté (intelligence-surfaces-11,
docs/audits/2026-09-03-full-codebase-audit.md) et prescrit exactement
la correction appliquée ici : la page lit maintenant les StoreTracker
des boutiques de l'org (déjà écrits par
/api/intelligence/hub/tracker, le bouton "Track in Brand Tracker" du
Store Spy), et le modèle mort a été supprimé du schéma.
Deux primitives portaient la même idée (suivre un domaine concurrent) :
StoreTracker (par boutique, réellement câblé) et IntelligenceWatchlist
(par org, jamais câblé). Il n'en reste qu'une.
Ce qui change concrètement :
src/services/algorithms/intelligence/org-tracker-rollup.ts— fonction purerollupOrgTrackers()qui fusionne lesStoreTrackerde toutes les boutiques d'une org en une liste dédupliquée par domaine (deux boutiques qui suivent le même concurrent ne produisent qu'une ligne, avec l'attribution des deux boutiques et la date d'ajout la plus ancienne)./[orgSlug]/intelligencelit désormaisStore.competitorTrackers(joinStoreTracker) au lieu deIntelligenceWatchlist, et affiche quelle(s) boutique(s) suivent chaque domaine — un effet de réseau réel et minimal : plus une org connecte de boutiques, plus son équipe profite du travail de veille de chacune, sans qu'aucune boutique n'ait à re-suivre un concurrent qu'une autre suit déjà.- L'état vide ne promet plus un outil
@Atlas"watch" qui n'a jamais existé : il pointe vers le Store Spy réel (/#boostecom-os), où vit le bouton "Track in Brand Tracker". model IntelligenceWatchlistet la relationOrganization.intelligenceWatchlistssont retirés deprisma/schema.prisma; le guard régénéré ne provisionne plus la table. La table physique (toujours vide, par construction) reste en base jusqu'à unprisma db pushopérateur — aucune perte de données possible puisqu'elle n'a jamais eu de ligne.