The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "SHOPPING---Product-Discovery-WebSearch",
"active": true,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "product-discovery",
"options": {
"responseMode": "responseNode"
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000001",
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
]
},
{
"parameters": {
"jsCode": "// ============================================\n// VALIDATION INPUT & EXTRACTION API KEY\n// ============================================\n\nconst input = $input.first().json;\nconst body = input.body || {};\nconst headers = input.headers || {};\n\n// --- Validation des champs requis ---\nif (!body.items || !Array.isArray(body.items) || body.items.length === 0) {\n return {\n error: true,\n error_code: 'MISSING_ITEMS',\n message: 'Le champ \"items\" est requis et doit \u00eatre un tableau non vide'\n };\n}\n\nif (!body.context || typeof body.context !== 'string') {\n return {\n error: true,\n error_code: 'MISSING_CONTEXT',\n message: 'Le champ \"context\" est requis'\n };\n}\n\n// --- Extraction API Key depuis headers ---\n// Cherche dans diff\u00e9rents formats de headers (lowercase/mixed)\nconst apiKey = headers['x-openai-api-key'] \n || headers['X-OpenAI-API-Key'] \n || headers['X-Openai-Api-Key'];\n\nif (!apiKey) {\n return {\n error: true,\n error_code: 'MISSING_API_KEY',\n message: 'Header X-OpenAI-API-Key requis'\n };\n}\n\n// --- Extraction Project ID ---\nconst projectId = headers['x-project-id'] \n || headers['X-Project-ID'] \n || headers['X-Project-Id']\n || 'unknown';\n\n// --- Pr\u00e9paration des items ---\nconst items = body.items.map(item => ({\n item_name: item.item_name || item.name || '',\n category: item.category || 'ingredient'\n}));\n\n// --- Retour des donn\u00e9es valid\u00e9es ---\nreturn {\n error: false,\n data: {\n items: items,\n context: body.context,\n discord_user_id: body.discord_user_id || null,\n locale: body.locale || 'fr-FR',\n project_id: projectId,\n items_count: items.length\n },\n credentials: {\n openai_api_key: apiKey\n }\n};"
},
"id": "a1b2c3d4-0001-0001-0001-000000000002",
"name": "Validate Input",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "check-error",
"leftValue": "={{ $json.error }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "a1b2c3d4-0001-0001-0001-000000000003",
"name": "Check Validation Error",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
440,
300
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.credentials.openai_api_key }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"gpt-4o\",\n \"temperature\": 0.3,\n \"response_format\": { \"type\": \"json_object\" },\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"Tu es un assistant shopping expert en cuisine fran\u00e7aise. Tu analyses une liste d'items pour les transformer en recommandations de produits concrets.\\n\\n## M\u00c9THODE DE RAISONNEMENT (4 COUCHES)\\n\\nPour CHAQUE item, applique ces 4 couches dans l'ordre :\\n\\n### Couche 1 - D\u00c9SAMBIGU\u00cfSATION LEXICALE\\n- Le terme est-il trop g\u00e9n\u00e9rique (hyperonyme) ?\\n- N\u00e9cessite-t-il une sp\u00e9cialisation minimale ?\\n- Exemple: \\\"farine\\\" est trop g\u00e9n\u00e9rique\\n\\n### Couche 2 - ANALYSE DU CONTEXTE D'USAGE\\n- Comment le contexte influence-t-il l'interpr\u00e9tation ?\\n- Passer d'un raisonnement 'objet' \u00e0 un raisonnement 'usage'\\n- Le contexte n'est PAS d\u00e9coratif, il est D\u00c9TERMINANT\\n\\n### Couche 3 - CONNAISSANCES CULTURELLES/PROBABILISTES\\n- Quelle est la probabilit\u00e9 par d\u00e9faut dans ce contexte ?\\n- Appliquer les r\u00e8gles de la cuisine fran\u00e7aise\\n- Les alternatives minoritaires doivent \u00eatre explicitement demand\u00e9es\\n- Exemple: cr\u00eapes (sans qualificatif) = farine de bl\u00e9 (98% des cas)\\n\\n### Couche 4 - PRINCIPE DE NON-SURSP\u00c9CIFICATION\\n- Ne JAMAIS \u00eatre plus pr\u00e9cis que ce que le contexte justifie\\n- Les variantes sont propos\u00e9es EN COMPL\u00c9MENT, pas comme v\u00e9rit\u00e9 unique\\n- Exemple: \\\"farine de bl\u00e9\\\" suffit, T45/T55 en variantes\\n\\n## R\u00c8GLES IMPORTANTES\\n- 1 item = 1 analyse compl\u00e8te\\n- Marques fran\u00e7aises connues pour search_query (Francine, Tefal, Lactel, Matines, etc.)\\n- Justification courte et claire pour l'utilisateur\\n- search_query doit \u00eatre efficace pour trouver un produit r\u00e9el\\n\\nRetourne UNIQUEMENT un JSON valide, sans texte avant ou apr\u00e8s.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"## Items \u00e0 analyser\\n{{ JSON.stringify($json.data.items) }}\\n\\n## Contexte d'utilisation\\n{{ $json.data.context }}\\n\\n## Locale\\n{{ $json.data.locale }}\\n\\n## Format de sortie attendu\\nRetourne ce JSON EXACT :\\n{\\n \\\"analysis\\\": [\\n {\\n \\\"original\\\": \\\"nom original de l'item\\\",\\n \\\"refined\\\": \\\"nom affin\u00e9 apr\u00e8s raisonnement\\\",\\n \\\"category\\\": \\\"ingredient ou ustensile\\\",\\n \\\"reasoning\\\": {\\n \\\"layers\\\": {\\n \\\"lexical\\\": \\\"analyse de la couche 1\\\",\\n \\\"context\\\": \\\"analyse de la couche 2\\\",\\n \\\"knowledge\\\": \\\"analyse de la couche 3\\\",\\n \\\"precision\\\": \\\"analyse de la couche 4\\\"\\n },\\n \\\"justification\\\": \\\"r\u00e9sum\u00e9 court pour affichage utilisateur\\\",\\n \\\"confidence\\\": 0.92,\\n \\\"variants\\\": [\\n {\\\"name\\\": \\\"variante possible\\\", \\\"reason\\\": \\\"pourquoi cette variante\\\"}\\n ]\\n },\\n \\\"search_query\\\": \\\"requ\u00eate optimis\u00e9e pour recherche produit avec marque fran\u00e7aise\\\"\\n }\\n ]\\n}\"\n }\n ]\n}",
"options": {
"timeout": 60000
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000004",
"name": "LLM Batch Reasoning",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
880,
200
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// ============================================\n// PARSE LLM RESPONSE & PREPARE ITEMS\n// ============================================\n\nconst llmResponse = $input.first().json;\nconst validationData = $('Validate Input').first().json;\n\n// --- V\u00e9rifier les erreurs OpenAI ---\nif (llmResponse.error) {\n return [{\n json: {\n error: true,\n error_code: 'OPENAI_ERROR',\n message: llmResponse.error.message || 'Erreur OpenAI',\n details: llmResponse.error\n }\n }];\n}\n\n// --- Parser la r\u00e9ponse ---\nlet analysis;\ntry {\n const content = llmResponse.choices[0].message.content;\n const parsed = JSON.parse(content);\n analysis = parsed.analysis;\n \n if (!analysis || !Array.isArray(analysis)) {\n throw new Error('Missing analysis array in response');\n }\n} catch (e) {\n return [{\n json: {\n error: true,\n error_code: 'PARSE_ERROR',\n message: 'Impossible de parser la r\u00e9ponse LLM: ' + e.message\n }\n }];\n}\n\n// --- Retourner chaque item s\u00e9par\u00e9ment pour traitement parall\u00e8le ---\nconst items = analysis.map((item, index) => ({\n json: {\n index: index,\n original: item.original,\n refined: item.refined,\n category: item.category,\n reasoning: item.reasoning,\n search_query: item.search_query,\n // Passer les credentials pour les appels suivants\n _credentials: validationData.credentials,\n _context: validationData.data.context\n }\n}));\n\nreturn items;"
},
"id": "a1b2c3d4-0001-0001-0001-000000000005",
"name": "Parse LLM Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
200
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "check-parse-error",
"leftValue": "={{ $json.error }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "a1b2c3d4-0001-0001-0001-000000000006",
"name": "Check Parse Error",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1320,
200
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json._credentials.openai_api_key }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"gpt-4o-mini\",\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" },\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"Tu es un assistant qui recherche des produits \u00e0 acheter en France. Tu dois retourner UN SEUL produit avec toutes ses informations.\\n\\n## Crit\u00e8res de s\u00e9lection\\n- Marque connue et fiable (fran\u00e7aise de pr\u00e9f\u00e9rence)\\n- Prix milieu de gamme (pas le moins cher, pas le plus cher)\\n- Disponible en grande surface fran\u00e7aise ou en ligne\\n- Image produit existante\\n\\n## Magasins de r\u00e9f\u00e9rence\\n- Carrefour, Leclerc, Auchan, Intermarch\u00e9\\n- Amazon.fr, Cdiscount\\n- Boulanger, Darty (pour ustensiles)\\n\\nRetourne UNIQUEMENT un JSON valide.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"## Produit recherch\u00e9\\n{{ $json.refined }}\\n\\n## Contexte\\n{{ $json._context }}\\n\\n## Requ\u00eate sugg\u00e9r\u00e9e\\n{{ $json.search_query }}\\n\\n## Format de sortie attendu\\nRetourne ce JSON EXACT avec des donn\u00e9es R\u00c9ALISTES pour des produits fran\u00e7ais :\\n{\\n \\\"product\\\": {\\n \\\"name\\\": \\\"Nom complet du produit avec marque et contenance/taille\\\",\\n \\\"description\\\": \\\"Description courte du produit (max 100 caract\u00e8res)\\\",\\n \\\"price_cents\\\": 189,\\n \\\"price_display\\\": \\\"1,89 \u20ac\\\",\\n \\\"currency\\\": \\\"EUR\\\",\\n \\\"brand\\\": \\\"Nom de la marque\\\",\\n \\\"seller\\\": \\\"Nom du magasin/site\\\",\\n \\\"url\\\": \\\"https://www.exemple.fr/produit\\\",\\n \\\"image_url\\\": \\\"https://www.exemple.fr/image.jpg\\\"\\n }\\n}\\n\\nIMPORTANT:\\n- Prix r\u00e9alistes en centimes (1,89\u20ac = 189)\\n- URLs plausibles pour des sites fran\u00e7ais\\n- Marques r\u00e9elles existantes en France\"\n }\n ]\n}",
"options": {
"timeout": 30000,
"batching": {
"batch": {
"batchSize": 10,
"batchInterval": 100
}
}
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000007",
"name": "Search Products (Parallel)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1760,
100
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// ============================================\n// AGGREGATE ALL RESULTS\n// ============================================\n\nconst searchResults = $input.all();\nconst parsedItems = $('Parse LLM Response').all();\nconst validationData = $('Validate Input').first().json;\n\nconst shoppingList = [];\nlet totalCents = 0;\n\nfor (let i = 0; i < searchResults.length; i++) {\n const searchResult = searchResults[i].json;\n const itemData = parsedItems[i].json;\n \n // --- Parser le produit trouv\u00e9 ---\n let product = null;\n \n try {\n if (searchResult.choices && searchResult.choices[0]) {\n const content = searchResult.choices[0].message.content;\n const parsed = JSON.parse(content);\n product = parsed.product;\n }\n } catch (e) {\n // Produit par d\u00e9faut si erreur\n product = null;\n }\n \n // --- Produit par d\u00e9faut si non trouv\u00e9 ---\n if (!product) {\n product = {\n name: `${itemData.refined} (non trouv\u00e9)`,\n description: 'Produit non disponible',\n price_cents: 0,\n price_display: '-- \u20ac',\n currency: 'EUR',\n brand: 'N/A',\n seller: 'N/A',\n url: null,\n image_url: null\n };\n }\n \n // --- Calcul du total ---\n if (product.price_cents && typeof product.price_cents === 'number') {\n totalCents += product.price_cents;\n }\n \n // --- Construire l'item de la liste ---\n shoppingList.push({\n original_item: itemData.original,\n refined_item: itemData.refined,\n category: itemData.category,\n reasoning: itemData.reasoning,\n product: product\n });\n}\n\n// --- Formater le total ---\nconst totalEuros = (totalCents / 100).toFixed(2).replace('.', ',');\nconst totalDisplay = `${totalEuros} \u20ac`;\n\n// --- Construire la r\u00e9ponse finale ---\nreturn {\n success: true,\n context: validationData.data.context,\n project_id: validationData.data.project_id,\n discord_user_id: validationData.data.discord_user_id,\n locale: validationData.data.locale,\n items_count: shoppingList.length,\n shopping_list: shoppingList,\n total_cents: totalCents,\n total_display: totalDisplay,\n metadata: {\n workflow_version: '2.0',\n reasoning_model: 'gpt-4o',\n search_model: 'gpt-4o-mini',\n timestamp: new Date().toISOString()\n }\n};"
},
"id": "a1b2c3d4-0001-0001-0001-000000000008",
"name": "Aggregate Results",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1980,
100
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000009",
"name": "Respond Success",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2200,
100
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={\n \"success\": false,\n \"error_code\": \"{{ $json.error_code || 'VALIDATION_ERROR' }}\",\n \"message\": \"{{ $json.message || 'Erreur de validation' }}\"\n}",
"options": {
"responseCode": 400,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000010",
"name": "Respond Validation Error",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
660,
400
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={\n \"success\": false,\n \"error_code\": \"{{ $json.error_code || 'PROCESSING_ERROR' }}\",\n \"message\": \"{{ $json.message || 'Erreur de traitement' }}\",\n \"details\": {{ JSON.stringify($json.details || null) }}\n}",
"options": {
"responseCode": 500,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000011",
"name": "Respond Processing Error",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1540,
300
]
},
{
"parameters": {
"content": "# \ud83d\uded2 Product Discovery Workflow v2.0\n\n## Architecture\n```\nWebhook \u2192 Validate \u2192 LLM Batch (4 couches) \u2192 Split \u2192 Search // \u2192 Aggregate \u2192 Response\n```\n\n## Endpoint\n```\nPOST /webhook/product-discovery\n```\n\n## Headers requis\n| Header | Description |\n|--------|-------------|\n| `X-OpenAI-API-Key` | Cl\u00e9 API OpenAI |\n| `X-Project-ID` | ID du plugin |\n\n## Body\n```json\n{\n \"items\": [\n {\"item_name\": \"farine\", \"category\": \"ingredient\"}\n ],\n \"context\": \"Pour faire des cr\u00eapes\",\n \"discord_user_id\": \"123...\",\n \"locale\": \"fr-FR\"\n}\n```\n\n## Raisonnement 4 couches\n1. **Lexical** - D\u00e9sambigu\u00efsation\n2. **Context** - Analyse usage\n3. **Knowledge** - Probabilit\u00e9s culturelles\n4. **Precision** - Non-sursp\u00e9cification\n\n## Output\n- `shopping_list` avec raisonnement\n- 1 produit par item\n- `total_cents` / `total_display`",
"height": 580,
"width": 340,
"color": 5
},
"id": "a1b2c3d4-0001-0001-0001-000000000012",
"name": "Documentation",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-340,
60
]
},
{
"parameters": {
"content": "## \ud83d\udd34 Erreurs Validation\n- MISSING_ITEMS\n- MISSING_CONTEXT \n- MISSING_API_KEY\n\n\u2192 HTTP 400",
"height": 140,
"width": 200,
"color": 3
},
"id": "a1b2c3d4-0001-0001-0001-000000000013",
"name": "Note Errors 400",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
640,
520
]
},
{
"parameters": {
"content": "## \ud83d\udfe0 Erreurs Processing\n- OPENAI_ERROR\n- PARSE_ERROR\n\n\u2192 HTTP 500",
"height": 140,
"width": 200,
"color": 6
},
"id": "a1b2c3d4-0001-0001-0001-000000000014",
"name": "Note Errors 500",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1520,
420
]
},
{
"parameters": {
"content": "## \ud83e\udde0 LLM Batch\n- Mod\u00e8le: **gpt-4o**\n- Analyse TOUS les items\n- Raisonnement 4 couches\n- 1 seul appel API",
"height": 140,
"width": 220,
"color": 4
},
"id": "a1b2c3d4-0001-0001-0001-000000000015",
"name": "Note LLM Batch",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
860,
340
]
},
{
"parameters": {
"content": "## \ud83d\udd0d Search Parallel\n- Mod\u00e8le: **gpt-4o-mini**\n- N appels en parall\u00e8le\n- 1 produit par item\n- Batch size: 10",
"height": 140,
"width": 220,
"color": 4
},
"id": "a1b2c3d4-0001-0001-0001-000000000016",
"name": "Note Search Parallel",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1740,
240
]
}
],
"connections": {
"Webhook Trigger": {
"main": [
[
{
"node": "Validate Input",
"type": "main",
"index": 0
}
]
]
},
"Validate Input": {
"main": [
[
{
"node": "Check Validation Error",
"type": "main",
"index": 0
}
]
]
},
"Check Validation Error": {
"main": [
[
{
"node": "Respond Validation Error",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM Batch Reasoning",
"type": "main",
"index": 0
}
]
]
},
"LLM Batch Reasoning": {
"main": [
[
{
"node": "Parse LLM Response",
"type": "main",
"index": 0
}
],
[
{
"node": "Parse LLM Response",
"type": "main",
"index": 0
}
]
]
},
"Parse LLM Response": {
"main": [
[
{
"node": "Check Parse Error",
"type": "main",
"index": 0
}
]
]
},
"Check Parse Error": {
"main": [
[
{
"node": "Respond Processing Error",
"type": "main",
"index": 0
}
],
[
{
"node": "Search Products (Parallel)",
"type": "main",
"index": 0
}
]
]
},
"Search Products (Parallel)": {
"main": [
[
{
"node": "Aggregate Results",
"type": "main",
"index": 0
}
]
]
},
"Aggregate Results": {
"main": [
[
{
"node": "Respond Success",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"callerPolicy": "workflowsFromSameOwner",
"availableInMCP": false
},
"tags": [],
"activeVersion": {
"updatedAt": "2026-02-02T17:55:15.060Z",
"createdAt": "2026-02-02T17:55:15.060Z",
"versionId": "82c50ad9-3d41-4ed0-bfca-670111fdff1c",
"workflowId": "39uw0mdSU5IPTJys",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "product-discovery",
"options": {
"responseMode": "responseNode"
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000001",
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"webhookId": "product-discovery-webhook"
},
{
"parameters": {
"jsCode": "// ============================================\n// VALIDATION INPUT & EXTRACTION API KEY\n// ============================================\n\nconst input = $input.first().json;\nconst body = input.body || {};\nconst headers = input.headers || {};\n\n// --- Validation des champs requis ---\nif (!body.items || !Array.isArray(body.items) || body.items.length === 0) {\n return {\n error: true,\n error_code: 'MISSING_ITEMS',\n message: 'Le champ \"items\" est requis et doit \u00eatre un tableau non vide'\n };\n}\n\nif (!body.context || typeof body.context !== 'string') {\n return {\n error: true,\n error_code: 'MISSING_CONTEXT',\n message: 'Le champ \"context\" est requis'\n };\n}\n\n// --- Extraction API Key depuis headers ---\n// Cherche dans diff\u00e9rents formats de headers (lowercase/mixed)\nconst apiKey = headers['x-openai-api-key'] \n || headers['X-OpenAI-API-Key'] \n || headers['X-Openai-Api-Key'];\n\nif (!apiKey) {\n return {\n error: true,\n error_code: 'MISSING_API_KEY',\n message: 'Header X-OpenAI-API-Key requis'\n };\n}\n\n// --- Extraction Project ID ---\nconst projectId = headers['x-project-id'] \n || headers['X-Project-ID'] \n || headers['X-Project-Id']\n || 'unknown';\n\n// --- Pr\u00e9paration des items ---\nconst items = body.items.map(item => ({\n item_name: item.item_name || item.name || '',\n category: item.category || 'ingredient'\n}));\n\n// --- Retour des donn\u00e9es valid\u00e9es ---\nreturn {\n error: false,\n data: {\n items: items,\n context: body.context,\n discord_user_id: body.discord_user_id || null,\n locale: body.locale || 'fr-FR',\n project_id: projectId,\n items_count: items.length\n },\n credentials: {\n openai_api_key: apiKey\n }\n};"
},
"id": "a1b2c3d4-0001-0001-0001-000000000002",
"name": "Validate Input",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "check-error",
"leftValue": "={{ $json.error }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "a1b2c3d4-0001-0001-0001-000000000003",
"name": "Check Validation Error",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
440,
300
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.credentials.openai_api_key }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"gpt-4o\",\n \"temperature\": 0.3,\n \"response_format\": { \"type\": \"json_object\" },\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"Tu es un assistant shopping expert en cuisine fran\u00e7aise. Tu analyses une liste d'items pour les transformer en recommandations de produits concrets.\\n\\n## M\u00c9THODE DE RAISONNEMENT (4 COUCHES)\\n\\nPour CHAQUE item, applique ces 4 couches dans l'ordre :\\n\\n### Couche 1 - D\u00c9SAMBIGU\u00cfSATION LEXICALE\\n- Le terme est-il trop g\u00e9n\u00e9rique (hyperonyme) ?\\n- N\u00e9cessite-t-il une sp\u00e9cialisation minimale ?\\n- Exemple: \\\"farine\\\" est trop g\u00e9n\u00e9rique\\n\\n### Couche 2 - ANALYSE DU CONTEXTE D'USAGE\\n- Comment le contexte influence-t-il l'interpr\u00e9tation ?\\n- Passer d'un raisonnement 'objet' \u00e0 un raisonnement 'usage'\\n- Le contexte n'est PAS d\u00e9coratif, il est D\u00c9TERMINANT\\n\\n### Couche 3 - CONNAISSANCES CULTURELLES/PROBABILISTES\\n- Quelle est la probabilit\u00e9 par d\u00e9faut dans ce contexte ?\\n- Appliquer les r\u00e8gles de la cuisine fran\u00e7aise\\n- Les alternatives minoritaires doivent \u00eatre explicitement demand\u00e9es\\n- Exemple: cr\u00eapes (sans qualificatif) = farine de bl\u00e9 (98% des cas)\\n\\n### Couche 4 - PRINCIPE DE NON-SURSP\u00c9CIFICATION\\n- Ne JAMAIS \u00eatre plus pr\u00e9cis que ce que le contexte justifie\\n- Les variantes sont propos\u00e9es EN COMPL\u00c9MENT, pas comme v\u00e9rit\u00e9 unique\\n- Exemple: \\\"farine de bl\u00e9\\\" suffit, T45/T55 en variantes\\n\\n## R\u00c8GLES IMPORTANTES\\n- 1 item = 1 analyse compl\u00e8te\\n- Marques fran\u00e7aises connues pour search_query (Francine, Tefal, Lactel, Matines, etc.)\\n- Justification courte et claire pour l'utilisateur\\n- search_query doit \u00eatre efficace pour trouver un produit r\u00e9el\\n\\nRetourne UNIQUEMENT un JSON valide, sans texte avant ou apr\u00e8s.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"## Items \u00e0 analyser\\n{{ JSON.stringify($json.data.items) }}\\n\\n## Contexte d'utilisation\\n{{ $json.data.context }}\\n\\n## Locale\\n{{ $json.data.locale }}\\n\\n## Format de sortie attendu\\nRetourne ce JSON EXACT :\\n{\\n \\\"analysis\\\": [\\n {\\n \\\"original\\\": \\\"nom original de l'item\\\",\\n \\\"refined\\\": \\\"nom affin\u00e9 apr\u00e8s raisonnement\\\",\\n \\\"category\\\": \\\"ingredient ou ustensile\\\",\\n \\\"reasoning\\\": {\\n \\\"layers\\\": {\\n \\\"lexical\\\": \\\"analyse de la couche 1\\\",\\n \\\"context\\\": \\\"analyse de la couche 2\\\",\\n \\\"knowledge\\\": \\\"analyse de la couche 3\\\",\\n \\\"precision\\\": \\\"analyse de la couche 4\\\"\\n },\\n \\\"justification\\\": \\\"r\u00e9sum\u00e9 court pour affichage utilisateur\\\",\\n \\\"confidence\\\": 0.92,\\n \\\"variants\\\": [\\n {\\\"name\\\": \\\"variante possible\\\", \\\"reason\\\": \\\"pourquoi cette variante\\\"}\\n ]\\n },\\n \\\"search_query\\\": \\\"requ\u00eate optimis\u00e9e pour recherche produit avec marque fran\u00e7aise\\\"\\n }\\n ]\\n}\"\n }\n ]\n}",
"options": {
"timeout": 60000
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000004",
"name": "LLM Batch Reasoning",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
880,
200
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// ============================================\n// PARSE LLM RESPONSE & PREPARE ITEMS\n// ============================================\n\nconst llmResponse = $input.first().json;\nconst validationData = $('Validate Input').first().json;\n\n// --- V\u00e9rifier les erreurs OpenAI ---\nif (llmResponse.error) {\n return [{\n json: {\n error: true,\n error_code: 'OPENAI_ERROR',\n message: llmResponse.error.message || 'Erreur OpenAI',\n details: llmResponse.error\n }\n }];\n}\n\n// --- Parser la r\u00e9ponse ---\nlet analysis;\ntry {\n const content = llmResponse.choices[0].message.content;\n const parsed = JSON.parse(content);\n analysis = parsed.analysis;\n \n if (!analysis || !Array.isArray(analysis)) {\n throw new Error('Missing analysis array in response');\n }\n} catch (e) {\n return [{\n json: {\n error: true,\n error_code: 'PARSE_ERROR',\n message: 'Impossible de parser la r\u00e9ponse LLM: ' + e.message\n }\n }];\n}\n\n// --- Retourner chaque item s\u00e9par\u00e9ment pour traitement parall\u00e8le ---\nconst items = analysis.map((item, index) => ({\n json: {\n index: index,\n original: item.original,\n refined: item.refined,\n category: item.category,\n reasoning: item.reasoning,\n search_query: item.search_query,\n // Passer les credentials pour les appels suivants\n _credentials: validationData.credentials,\n _context: validationData.data.context\n }\n}));\n\nreturn items;"
},
"id": "a1b2c3d4-0001-0001-0001-000000000005",
"name": "Parse LLM Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
200
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "check-parse-error",
"leftValue": "={{ $json.error }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "a1b2c3d4-0001-0001-0001-000000000006",
"name": "Check Parse Error",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1320,
200
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json._credentials.openai_api_key }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"gpt-4o-mini\",\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" },\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"Tu es un assistant qui recherche des produits \u00e0 acheter en France. Tu dois retourner UN SEUL produit avec toutes ses informations.\\n\\n## Crit\u00e8res de s\u00e9lection\\n- Marque connue et fiable (fran\u00e7aise de pr\u00e9f\u00e9rence)\\n- Prix milieu de gamme (pas le moins cher, pas le plus cher)\\n- Disponible en grande surface fran\u00e7aise ou en ligne\\n- Image produit existante\\n\\n## Magasins de r\u00e9f\u00e9rence\\n- Carrefour, Leclerc, Auchan, Intermarch\u00e9\\n- Amazon.fr, Cdiscount\\n- Boulanger, Darty (pour ustensiles)\\n\\nRetourne UNIQUEMENT un JSON valide.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"## Produit recherch\u00e9\\n{{ $json.refined }}\\n\\n## Contexte\\n{{ $json._context }}\\n\\n## Requ\u00eate sugg\u00e9r\u00e9e\\n{{ $json.search_query }}\\n\\n## Format de sortie attendu\\nRetourne ce JSON EXACT avec des donn\u00e9es R\u00c9ALISTES pour des produits fran\u00e7ais :\\n{\\n \\\"product\\\": {\\n \\\"name\\\": \\\"Nom complet du produit avec marque et contenance/taille\\\",\\n \\\"description\\\": \\\"Description courte du produit (max 100 caract\u00e8res)\\\",\\n \\\"price_cents\\\": 189,\\n \\\"price_display\\\": \\\"1,89 \u20ac\\\",\\n \\\"currency\\\": \\\"EUR\\\",\\n \\\"brand\\\": \\\"Nom de la marque\\\",\\n \\\"seller\\\": \\\"Nom du magasin/site\\\",\\n \\\"url\\\": \\\"https://www.exemple.fr/produit\\\",\\n \\\"image_url\\\": \\\"https://www.exemple.fr/image.jpg\\\"\\n }\\n}\\n\\nIMPORTANT:\\n- Prix r\u00e9alistes en centimes (1,89\u20ac = 189)\\n- URLs plausibles pour des sites fran\u00e7ais\\n- Marques r\u00e9elles existantes en France\"\n }\n ]\n}",
"options": {
"timeout": 30000,
"batching": {
"batch": {
"batchSize": 10,
"batchInterval": 100
}
}
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000007",
"name": "Search Products (Parallel)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1760,
100
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// ============================================\n// AGGREGATE ALL RESULTS\n// ============================================\n\nconst searchResults = $input.all();\nconst parsedItems = $('Parse LLM Response').all();\nconst validationData = $('Validate Input').first().json;\n\nconst shoppingList = [];\nlet totalCents = 0;\n\nfor (let i = 0; i < searchResults.length; i++) {\n const searchResult = searchResults[i].json;\n const itemData = parsedItems[i].json;\n \n // --- Parser le produit trouv\u00e9 ---\n let product = null;\n \n try {\n if (searchResult.choices && searchResult.choices[0]) {\n const content = searchResult.choices[0].message.content;\n const parsed = JSON.parse(content);\n product = parsed.product;\n }\n } catch (e) {\n // Produit par d\u00e9faut si erreur\n product = null;\n }\n \n // --- Produit par d\u00e9faut si non trouv\u00e9 ---\n if (!product) {\n product = {\n name: `${itemData.refined} (non trouv\u00e9)`,\n description: 'Produit non disponible',\n price_cents: 0,\n price_display: '-- \u20ac',\n currency: 'EUR',\n brand: 'N/A',\n seller: 'N/A',\n url: null,\n image_url: null\n };\n }\n \n // --- Calcul du total ---\n if (product.price_cents && typeof product.price_cents === 'number') {\n totalCents += product.price_cents;\n }\n \n // --- Construire l'item de la liste ---\n shoppingList.push({\n original_item: itemData.original,\n refined_item: itemData.refined,\n category: itemData.category,\n reasoning: itemData.reasoning,\n product: product\n });\n}\n\n// --- Formater le total ---\nconst totalEuros = (totalCents / 100).toFixed(2).replace('.', ',');\nconst totalDisplay = `${totalEuros} \u20ac`;\n\n// --- Construire la r\u00e9ponse finale ---\nreturn {\n success: true,\n context: validationData.data.context,\n project_id: validationData.data.project_id,\n discord_user_id: validationData.data.discord_user_id,\n locale: validationData.data.locale,\n items_count: shoppingList.length,\n shopping_list: shoppingList,\n total_cents: totalCents,\n total_display: totalDisplay,\n metadata: {\n workflow_version: '2.0',\n reasoning_model: 'gpt-4o',\n search_model: 'gpt-4o-mini',\n timestamp: new Date().toISOString()\n }\n};"
},
"id": "a1b2c3d4-0001-0001-0001-000000000008",
"name": "Aggregate Results",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1980,
100
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000009",
"name": "Respond Success",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2200,
100
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={\n \"success\": false,\n \"error_code\": \"{{ $json.error_code || 'VALIDATION_ERROR' }}\",\n \"message\": \"{{ $json.message || 'Erreur de validation' }}\"\n}",
"options": {
"responseCode": 400,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000010",
"name": "Respond Validation Error",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
660,
400
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={\n \"success\": false,\n \"error_code\": \"{{ $json.error_code || 'PROCESSING_ERROR' }}\",\n \"message\": \"{{ $json.message || 'Erreur de traitement' }}\",\n \"details\": {{ JSON.stringify($json.details || null) }}\n}",
"options": {
"responseCode": 500,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
}
},
"id": "a1b2c3d4-0001-0001-0001-000000000011",
"name": "Respond Processing Error",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1540,
300
]
},
{
"parameters": {
"content": "# \ud83d\uded2 Product Discovery Workflow v2.0\n\n## Architecture\n```\nWebhook \u2192 Validate \u2192 LLM Batch (4 couches) \u2192 Split \u2192 Search // \u2192 Aggregate \u2192 Response\n```\n\n## Endpoint\n```\nPOST /webhook/product-discovery\n```\n\n## Headers requis\n| Header | Description |\n|--------|-------------|\n| `X-OpenAI-API-Key` | Cl\u00e9 API OpenAI |\n| `X-Project-ID` | ID du plugin |\n\n## Body\n```json\n{\n \"items\": [\n {\"item_name\": \"farine\", \"category\": \"ingredient\"}\n ],\n \"context\": \"Pour faire des cr\u00eapes\",\n \"discord_user_id\": \"123...\",\n \"locale\": \"fr-FR\"\n}\n```\n\n## Raisonnement 4 couches\n1. **Lexical** - D\u00e9sambigu\u00efsation\n2. **Context** - Analyse usage\n3. **Knowledge** - Probabilit\u00e9s culturelles\n4. **Precision** - Non-sursp\u00e9cification\n\n## Output\n- `shopping_list` avec raisonnement\n- 1 produit par item\n- `total_cents` / `total_display`",
"height": 580,
"width": 340,
"color": 5
},
"id": "a1b2c3d4-0001-0001-0001-000000000012",
"name": "Documentation",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-340,
60
]
},
{
"parameters": {
"content": "## \ud83d\udd34 Erreurs Validation\n- MISSING_ITEMS\n- MISSING_CONTEXT \n- MISSING_API_KEY\n\n\u2192 HTTP 400",
"height": 140,
"width": 200,
"color": 3
},
"id": "a1b2c3d4-0001-0001-0001-000000000013",
"name": "Note Errors 400",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
640,
520
]
},
{
"parameters": {
"content": "## \ud83d\udfe0 Erreurs Processing\n- OPENAI_ERROR\n- PARSE_ERROR\n\n\u2192 HTTP 500",
"height": 140,
"width": 200,
"color": 6
},
"id": "a1b2c3d4-0001-0001-0001-000000000014",
"name": "Note Errors 500",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1520,
420
]
},
{
"parameters": {
"content": "## \ud83e\udde0 LLM Batch\n- Mod\u00e8le: **gpt-4o**\n- Analyse TOUS les items\n- Raisonnement 4 couches\n- 1 seul appel API",
"height": 140,
"width": 220,
"color": 4
},
"id": "a1b2c3d4-0001-0001-0001-000000000015",
"name": "Note LLM Batch",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
860,
340
]
},
{
"parameters": {
"content": "## \ud83d\udd0d Search Parallel\n- Mod\u00e8le: **gpt-4o-mini**\n- N appels en parall\u00e8le\n- 1 produit par item\n- Batch size: 10",
"height": 140,
"width": 220,
"color": 4
},
"id": "a1b2c3d4-0001-0001-0001-000000000016",
"name": "Note Search Parallel",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1740,
240
]
}
],
"connections": {
"Webhook Trigger": {
"main": [
[
{
"node": "Validate Input",
"type": "main",
"index": 0
}
]
]
},
"Validate Input": {
"main": [
[
{
"node": "Check Validation Error",
"type": "main",
"index": 0
}
]
]
},
"Check Validation Error": {
"main": [
[
{
"node": "Respond Validation Error",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM Batch Reasoning",
"type": "main",
"index": 0
}
]
]
},
"LLM Batch Reasoning": {
"main": [
[
{
"node": "Parse LLM Response",
"type": "main",
"index": 0
}
],
[
{
"node": "Parse LLM Response",
"type": "main",
"index": 0
}
]
]
},
"Parse LLM Response": {
"main": [
[
{
"node": "Check Parse Error",
"type": "main",
"index": 0
}
]
]
},
"Check Parse Error": {
"main": [
[
{
"node": "Respond Processing Error",
"type": "main",
"index": 0
}
],
[
{
"node": "Search Products (Parallel)",
"type": "main",
"index": 0
}
]
]
},
"Search Products (Parallel)": {
"main": [
[
{
"node": "Aggregate Results",
"type": "main",
"index": 0
}
]
]
},
"Aggregate Results": {
"main": [
[
{
"node": "Respond Success",
"type": "main",
"index": 0
}
]
]
}
},
"authors": "import",
"name": null,
"description": null,
"autosaved": false,
"workflowPublishHistory": []
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
SHOPPING---Product-Discovery-WebSearch. Uses httpRequest. Webhook trigger; 16 nodes.
Source: https://github.com/fsebbah/n8n-workflows/blob/1f6333cb62836280134c21ad24b0f0b69e76ea3e/workflows/SHOPPING---Product-Discovery-WebSearch.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This n8n template provides enterprise-level version control for your workflows using GitHub integration. Stop losing hours to broken workflows and manual exports – get proper commit history, visual di
This flow creates dummy files for every item added in your *Arrs (Radarr/Sonarr) with the tag .
eek-Go v2 (Batch-Then-Review). Uses httpRequest. Webhook trigger; 75 nodes.
This workflow receives webhook requests from a content calendar and uses the X API v2 to publish text posts, threads, image/video posts, and polls, as well as delete existing posts and run a credentia
This workflow acts as a central API gateway for all technical indicator agents in the Binance Spot Market Quant AI system. It listens for incoming webhook requests and dynamically routes them to the c