diff --git a/apps/parapharmacy-api/package.json b/apps/parapharmacy-api/package.json index 7833417..79c42c4 100644 --- a/apps/parapharmacy-api/package.json +++ b/apps/parapharmacy-api/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "node --env-file-if-exists=.env src/server.js", "dev": "node --env-file-if-exists=.env --watch src/server.js", + "seed": "node --env-file-if-exists=.env scripts/seed.js", "test": "NODE_OPTIONS='--experimental-vm-modules' npx jest --ci --forceExit --forceExitTimeout=30000" }, "dependencies": { diff --git a/apps/parapharmacy-api/scripts/seed.js b/apps/parapharmacy-api/scripts/seed.js new file mode 100644 index 0000000..6b0f374 --- /dev/null +++ b/apps/parapharmacy-api/scripts/seed.js @@ -0,0 +1,111 @@ +import axios from 'axios'; + +const API_URL = process.env.PARAPHARMACY_API_URL || 'http://localhost:3002'; + +// Common parapharmacy search queries +const QUERIES = [ + 'crema hidratante', + 'protector solar', + 'champú', + 'gel ducha', + 'vitaminas', + 'paracetamol', + 'ibuprofeno', + 'omeprazol', + 'spray nasal', + 'gotas para los ojos', + 'crema manos', + 'bálsamo labial', + 'antiséptico', + 'vendas', + 'termómetro' +]; + +// Simulated products for testing (replace with real scraping) +const MOCK_PRODUCTS = [ + { name: 'Bioderma Atoderm Crema Hidratante 500ml', brand: 'Bioderma', category: 'Dermocosmética', price: 18.95 }, + { name: 'La Roche-Posay Anthelios Airlicium FPS50+', brand: 'La Roche-Posay', category: 'Solar', price: 19.95 }, + { name: 'Mustela Gel de Ducha 500ml', brand: 'Mustela', category: 'Bebé', price: 12.50 }, + { name: 'Centrum Multivitaminicos 30 comprimidos', brand: 'Centrum', category: 'Vitaminas', price: 15.80 }, + { name: 'Dolocordalpan 1g Paracetamol 20 sobres', brand: 'Dolocordalpan', category: 'Analgésicos', price: 4.95 }, + { name: 'Nurofen Flash 400mg 20 cápsulas', brand: 'Nurofen', category: 'Antiinflamatorios', price: 6.75 }, + { name: 'Omeprazol Cinfa 20mg 28 cápsulas', brand: 'Cinfa', category: 'Gastrointestinal', price: 8.50 }, + { name: 'Vichy Mineral 89 Sérum Hidratante 30ml', brand: 'Vichy', category: 'Dermocosmética', price: 25.90 }, + { name: 'Avène Agua Termal 300ml', brand: 'Avène', category: 'Dermocosmética', price: 9.95 }, + { name: 'CeraVe Crema Hidratante 340g', brand: 'CeraVe', category: 'Dermocosmética', price: 14.95 }, + { name: 'Ibuprofeno Alter 600mg 20 comprimidos', brand: 'Alter', category: 'Antiinflamatorios', price: 5.20 }, + { name: 'Salonpas Parches Analgésicos 5 unidades', brand: 'Salonpas', category: 'Analgésicos', price: 7.80 }, + { name: 'Fisiomer Spray Nasal 135ml', brand: 'Fisiomer', category: 'Respiratorio', price: 11.50 }, + { name: 'Thealoz Duo Colirio 10ml', brand: 'Thea', category: 'Oftalmología', price: 12.95 }, + { name: 'Neutrogena Crema Manos Noruega 50ml', brand: 'Neutrogena', category: 'Dermocosmética', price: 4.50 }, + { name: 'Capricare 1 Leche en polvo 400g', brand: 'Capricare', category: 'Fórmulas lácteas', price: 14.95 }, + { name: 'Nutribén 2 Leche 800g', brand: 'Nutribén', category: 'Fórmulas lácteas', price: 16.50 }, + { name: 'Bebelin Vitamina C 1g 20 comprimidos', brand: 'Bebelín', category: 'Vitaminas', price: 6.95 }, + { name: 'Lacer Pasta Dientes Sensibilidad 75ml', brand: 'Lacer', category: 'Oral', price: 5.80 }, + { name: 'Fotosan Crema Solar FPS50 200ml', brand: 'Fotosan', category: 'Solar', price: 22.50 } +]; + +async function checkAPIHealth() { + try { + const response = await axios.get(`${API_URL}/api/health`); + return response.data.status === 'ok'; + } catch (error) { + return false; + } +} + +async function seedProducts() { + console.log('🌱 Starting parapharmacy database seed...\n'); + + // Check if API is running + const apiHealthy = await checkAPIHealth(); + if (!apiHealthy) { + console.error('❌ Parapharmacy API is not running. Start it with: npm run dev'); + process.exit(1); + } + console.log('✅ Parapharmacy API is running\n'); + + // Seed products + let created = 0; + let updated = 0; + let errors = 0; + + for (const product of MOCK_PRODUCTS) { + try { + const productData = { + ...product, + source: 'seed', + source_product_id: `seed_${product.name.toLowerCase().replace(/[^a-z0-9]/g, '_')}`, + source_url: `https://www.promofarma.com/es/search?q=${encodeURIComponent(product.name)}`, + image_url: null, + currency: 'EUR', + available: true, + scraped_at: new Date().toISOString() + }; + + await axios.post(`${API_URL}/api/products`, productData); + created++; + console.log(` ✅ Created: ${product.name}`); + } catch (error) { + if (error.response?.status === 409) { + updated++; + console.log(` ⏭️ Already exists: ${product.name}`); + } else { + errors++; + console.error(` ❌ Error: ${product.name} - ${error.message}`); + } + } + } + + console.log('\n📊 Seed Summary:'); + console.log(` Created: ${created}`); + console.log(` Already exists: ${updated}`); + console.log(` Errors: ${errors}`); + console.log(` Total: ${MOCK_PRODUCTS.length}`); +} + +// Run seed +seedProducts().catch(error => { + console.error('❌ Seed failed:', error.message); + process.exit(1); +}); diff --git a/docs/parapharmacy.md b/docs/parapharmacy.md index 1575791..9ab1257 100644 --- a/docs/parapharmacy.md +++ b/docs/parapharmacy.md @@ -142,33 +142,43 @@ MONGODB_URI=mongodb://mongodb:27017/parapharmacy ## Workflows de Scraping -### 1. Parapharmacy Scraper (Automático) +| Workflow | Trigger | Estado | Descripción | +|----------|---------|--------|-------------| +| Parapharmacy Scraper - All Sources | Cada 3 días a las 2am | Inactivo | Scraping automático de las 6 tiendas | +| Parapharmacy Manual Scraper | POST `/webhook/scrape-all` | Activo | Scraping manual bajo demanda | + +### 1. Parapharmacy Scraper - All Sources (Automático) - **Trigger**: Cada 3 días a las 2:00 AM - **Estado**: Inactivo por defecto -- **Función**: Scraping automático de Promofarma +- **Función**: Scraping automático de las 6 tiendas **Para activar:** 1. Ir a http://localhost:5678/workflows -2. Abrir "Parapharmacy Scraper" +2. Abrir "Parapharmacy Scraper - All Sources" 3. Hacer clic en "Active" toggle ### 2. Parapharmacy Manual Scraper (Webhook) -- **Trigger**: POST a `/webhook/scrape-parapharmacy` +- **Trigger**: POST a `/webhook/scrape-all` - **Estado**: Activo por defecto -- **Función**: Scraping bajo demanda +- **Función**: Scraping bajo demanda de todas las fuentes **Uso:** ```bash -# Scraping con queries por defecto -curl -X POST http://localhost:5678/webhook/scrape-parapharmacy +# Scraping con queries por defecto (todas las tiendas) +curl -X POST http://localhost:5678/webhook/scrape-all # Scraping con queries específicas -curl -X POST http://localhost:5678/webhook/scrape-parapharmacy \ +curl -X POST http://localhost:5678/webhook/scrape-all \ -H "Content-Type: application/json" \ -d '{"queries": ["crema hidratante", "protector solar"]}' + +# Scraping solo de fuentes específicas +curl -X POST http://localhost:5678/webhook/scrape-all \ + -H "Content-Type: application/json" \ + -d '{"sources": ["promofarma", "pharmarket"]}' ``` --- @@ -177,12 +187,42 @@ curl -X POST http://localhost:5678/webhook/scrape-parapharmacy \ | Fuente | URL | Estado | |--------|-----|--------| -| Promofarma | promofarma.com | ✅ Implementado | -| Pharmarket | pharmarket.es | 🔄 Pendiente | -| DocMorris | docmorris.es | 🔄 Pendiente | -| 1001Farma | 1001farma.net | 🔄 Pendiente | -| Primor | primor.eu | 🔄 Pendiente | -| MiFarma | mifarma.es | 🔄 Pendiente | +| Promofarma | promofarma.com | ✅ Configurado | +| Pharmarket | pharmarket.es | ✅ Configurado | +| DocMorris | docmorris.es | ✅ Configurado | +| 1001Farma | 1001farma.net | ✅ Configurado | +| Primor | primor.eu | ✅ Configurado | +| MiFarma | mifarma.es | ✅ Configurado | + +--- + +## Poblar Base de Datos + +### Datos de Prueba + +```bash +# Ejecutar seed script (inserta 20 productos de prueba) +cd apps/parapharmacy-api +npm run seed +``` + +### Scraping Real + +```bash +# Ejecutar scraping manual via N8N webhook +curl -X POST http://localhost:5678/webhook/scrape-all +``` + +### Verificar Datos + +```bash +# Buscar productos +curl "http://localhost:3002/api/products/search?q=crema" + +# Contar productos +curl "http://localhost:3002/api/products?limit=1" +# Respuesta: { "total": 20, ... } +``` --- diff --git a/n8n/README.md b/n8n/README.md index 743de0d..03e40cd 100644 --- a/n8n/README.md +++ b/n8n/README.md @@ -1,89 +1,164 @@ # N8N Workflows for FarmaFinder Parapharmacy +## Workflows Disponibles + +| Workflow | Trigger | Estado | Descripción | +|----------|---------|--------|-------------| +| Parapharmacy Scraper - All Sources | Cada 3 días a las 2am | Inactivo | Scraping automático de las 6 tiendas | +| Parapharmacy Manual Scraper | POST `/webhook/scrape-all` | Activo | Scraping manual bajo demanda | + ## Configuración Inicial -Al iniciar N8N por primera vez, se creará automáticamente una cuenta de administrador: +### 1. Cuenta de Administrador -- **Email**: admin@farmafinder.com (configurable en `.env`) -- **Password**: change-me (configurable en `.env`) +Al iniciar N8N por primera vez, se crea automáticamente: -### Variables de Entorno +| Campo | Valor | +|-------|-------| +| **Email** | admin@farmafinder.com | +| **Password** | change-me | + +> **IMPORTANTE**: Cambia la contraseña después del primer login en http://localhost:5678/settings + +### 2. Variables de Entorno ```bash -# En el archivo .env de la raíz del proyecto +# En .env N8N_USER=admin N8N_PASSWORD=change-me N8N_EMAIL=admin@farmafinder.com ``` -## Workflows Incluidos +### 3. Importar Workflows -### 1. Parapharmacy Scraper (Automático) +Los workflows se importan automáticamente al iniciar el contenedor. Para importar manualmente: -- **Trigger**: Cada 3 días a las 2:00 AM -- **Función**: Scraping automático de Promofarma -- **Estado**: Inactivo por defecto (activar después de configurar) +1. Ir a http://localhost:5678/workflows +2. Hacer clic en "Import from File" +3. Seleccionar el archivo JSON de `n8n/workflows/` -### 2. Parapharmacy Manual Scraper (Webhook) +## Fuentes de Scraping -- **Trigger**: POST a `/webhook/scrape-parapharmacy` -- **Función**: Scraping bajo demanda -- **Estado**: Activo por defecto +| Fuente | URL Base | Estado | +|--------|----------|--------| +| Promofarma | promofarma.com | ✅ Configurado | +| Pharmarket | pharmarket.es | ✅ Configurado | +| DocMorris | docmorris.es | ✅ Configurado | +| 1001Farma | 1001farma.net | ✅ Configurado | +| Primor | primor.eu | ✅ Configurado | +| MiFarma | mifarma.es | ✅ Configurado | -## Uso del Webhook Manual +## Uso + +### Scraping Automático + +El workflow "Parapharmacy Scraper - All Sources" se ejecuta automáticamente cada 3 días. + +**Para activarlo:** +1. Ir a http://localhost:5678/workflows +2. Abrir "Parapharmacy Scraper - All Sources" +3. Activar el toggle "Active" + +### Scraping Manual ```bash -# Ejecutar scraping con queries por defecto -curl -X POST http://localhost:5678/webhook/scrape-parapharmacy +# Scraping con queries por defecto (todas las tiendas) +curl -X POST http://localhost:5678/webhook/scrape-all -# Ejecutar scraping con queries específicas -curl -X POST http://localhost:5678/webhook/scrape-parapharmacy \ +# Scraping con queries específicas +curl -X POST http://localhost:5678/webhook/scrape-all \ -H "Content-Type: application/json" \ -d '{"queries": ["crema hidratante", "protector solar"]}' + +# Scraping solo de fuentes específicas +curl -X POST http://localhost:5678/webhook/scrape-all \ + -H "Content-Type: application/json" \ + -d '{"sources": ["promofarma", "pharmarket"]}' ``` -## Endpoints de la API de Parafarmacia +### Respuesta del Webhook -Los workflows envían los productos scrapeados a: +```json +{ + "success": true, + "message": "Scraping completed", + "products": 15 +} +``` + +## Endpoints de la API + +Los workflows envían productos a: ``` POST http://parapharmacy-api:3002/api/products/bulk ``` +## Poblar Base de Datos + +### Datos de Prueba + +```bash +# Ejecutar seed script +cd apps/parapharmacy-api +npm run seed +``` + +Esto inserta 20 productos de prueba en la base de datos. + +### Scraping Real + +```bash +# Ejecutar scraping manual +curl -X POST http://localhost:5678/webhook/scrape-all +``` + ## Monitoreo -- **N8N Dashboard**: http://localhost:5678 +- **Dashboard N8N**: http://localhost:5678 - **Historial de ejecuciones**: http://localhost:5678/executions +- **Workflows**: http://localhost:5678/workflows - **Logs**: `docker logs n8n` -## Adding More Sources - -Para añadir nuevas fuentes de scraping: - -1. Crear un nuevo nodo HTTP Request en el workflow -2. Añadir un nodo Code para extraer los productos -3. Conectar al nodo "Send to Parapharmacy API" -4. Activar el workflow - ## Troubleshooting ### N8N muestra /setup -Si N8N muestra la página de configuración, verifica que las variables de entorno estén configuradas: - ```bash -N8N_OWNER_EMAIL=admin@farmafinder.com -N8N_OWNER_PASSWORD=change-me +# Verificar variables de entorno +docker-compose exec n8n env | grep N8N ``` ### Webhook no funciona -1. Verifica que el workflow esté activo -2. Revisa el historial de ejecuciones en N8N -3. Verifica los logs: `docker logs n8n` +1. Verificar que el workflow esté activo +2. Revisar historial de ejecuciones +3. Verificar logs: `docker logs n8n` + +### Scraping falla + +1. Verificar que parapharmacy-api esté corriendo +2. Verificar conexión a MongoDB +3. Revisar logs de ejecución en N8N +4. Probar con una sola fuente: `{"sources": ["promofarma"]}` ### Productos no se guardan -1. Verifica que parapharmacy-api esté ejecutándose -2. Revisa los logs de la API: `docker logs parapharmacy-api` -3. Verifica que MongoDB esté conectado +```bash +# Verificar health check +curl http://localhost:3002/api/health + +# Verificar MongoDB +docker-compose exec mongodb mongosh --eval "db.adminCommand('ping')" +``` + +## Adding New Sources + +Para añadir una nueva fuente: + +1. Crear un nuevo nodo en el workflow +2. Configurar la URL de la fuente +3. Añadir selectores CSS para extraer productos +4. Conectar al nodo "Send to API" +5. Probar con webhook manual +6. Activar el workflow diff --git a/n8n/workflows/parapharmacy-all-sources.json b/n8n/workflows/parapharmacy-all-sources.json new file mode 100644 index 0000000..b37d6ad --- /dev/null +++ b/n8n/workflows/parapharmacy-all-sources.json @@ -0,0 +1,134 @@ +{ + "name": "Parapharmacy Scraper - All Sources", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "0 2 */3 * *" + } + ] + } + }, + "id": "schedule", + "name": "Schedule (Every 3 days)", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [220, 300] + }, + { + "parameters": { + "values": { + "string": [ + { + "name": "queries", + "value": "crema hidratante,protector solar,leche corporal,champú bebé,aceite bebé,crema pañal,vitaminas,fórmula láctea,paracetamol,ibuprofeno" + } + ] + }, + "options": {} + }, + "id": "set-queries", + "name": "Set Queries", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [440, 300] + }, + { + "parameters": { + "fieldToSplitOut": "queries", + "options": {} + }, + "id": "split-queries", + "name": "Split Queries", + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, + "position": [660, 300] + }, + { + "parameters": { + "jsCode": "// Define all sources to scrape\nconst query = $input.first().json.queries;\n\nconst sources = [\n {\n name: 'promofarma',\n url: `https://www.promofarma.com/es/search?q=${encodeURIComponent(query)}`,\n selectors: {\n product: '.product-card, article[class*=\"product\"]',\n name: 'h3, h2, [class*=\"title\"]',\n price: '[class*=\"price\"], .current-price',\n link: 'a',\n image: 'img'\n }\n },\n {\n name: 'pharmarket',\n url: `https://www.pharmarket.es/catalogsearch/result/?q=${encodeURIComponent(query)}`,\n selectors: {\n product: '.product-item, .item.product',\n name: '.product-item-link, .product-name',\n price: '.price',\n link: 'a.product-item-link',\n image: 'img.product-image-photo'\n }\n },\n {\n name: 'docmorris',\n url: `https://www.docmorris.es/search?query=${encodeURIComponent(query)}`,\n selectors: {\n product: '[class*=\"product-card\"], [class*=\"product-item\"]',\n name: '[class*=\"product-name\"], h3',\n price: '[class*=\"price\"]',\n link: 'a',\n image: 'img'\n }\n },\n {\n name: '1001farma',\n url: `https://www.1001farma.net/buscar?s=${encodeURIComponent(query)}`,\n selectors: {\n product: '.product-miniature, article.product-miniature',\n name: '.product-title a, h3 a',\n price: '.price',\n link: '.product-title a',\n image: 'img'\n }\n },\n {\n name: 'primor',\n url: `https://www.primor.eu/search?s=${encodeURIComponent(query)}`,\n selectors: {\n product: '.product-item, .product-miniature',\n name: '.product-title, h3',\n price: '.price',\n link: 'a.product-title',\n image: 'img'\n }\n },\n {\n name: 'mifarma',\n url: `https://www.mifarma.es/buscador?q=${encodeURIComponent(query)}`,\n selectors: {\n product: '.product-card, .product-item',\n name: '.product-name, h3',\n price: '.price',\n link: 'a',\n image: 'img'\n }\n }\n];\n\nreturn sources.map(s => ({ json: { ...s, query } }));" + }, + "id": "define-sources", + "name": "Define Sources", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [880, 300] + }, + { + "parameters": { + "method": "GET", + "url": "={{ $json.url }}", + "options": { + "timeout": 30000 + } + }, + "id": "scrape-source", + "name": "Scrape Source", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1100, 300] + }, + { + "parameters": { + "jsCode": "// Extract products based on source\nconst sourceData = $input.first().json;\nconst html = sourceData.data || '';\nconst source = sourceData.name;\n\nconst products = [];\n\n// Common extraction patterns\nconst patterns = {\n promofarma: {\n productRegex: /