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: /]*class=\"[^\"]*product-card[^\"]*\"[^>]*>([\\s\\S]*?)<\\/div>\\s*<\\/div>/gi,\n nameRegex: /]*>([^<]+)<\\/h[23]>/i,\n priceRegex: /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i,\n linkRegex: /]*href=\"([^\"]+)\"[^>]*>/i,\n imageRegex: /]*src=\"([^\"]+)\"[^>]*>/i\n },\n pharmarket: {\n productRegex: /]*class=\"[^\"]*product-item[^\"]*\"[^>]*>([\\s\\S]*?)<\\/li>/gi,\n nameRegex: /class=\"[^\"]*product-item-link[^\"]*\"[^>]*>([^<]+)<\\/a>/i,\n priceRegex: /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i,\n linkRegex: /class=\"[^\"]*product-item-link[^\"]*\"[^>]*href=\"([^\"]+)\"/i,\n imageRegex: /]*class=\"[^\"]*product-image[^\"]*\"[^>]*src=\"([^\"]+)\"/i\n },\n docmorris: {\n productRegex: /]*class=\"[^\"]*product-card[^\"]*\"[^>]*>([\\s\\S]*?)<\\/div>/gi,\n nameRegex: /class=\"[^\"]*product-name[^\"]*\"[^>]*>([^<]+)<\\/[^>]+>/i,\n priceRegex: /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i,\n linkRegex: /]*href=\"([^\"]+)\"[^>]*>/i,\n imageRegex: /]*src=\"([^\"]+)\"[^>]*>/i\n },\n '1001farma': {\n productRegex: /]*class=\"[^\"]*product-miniature[^\"]*\"[^>]*>([\\s\\S]*?)<\\/article>/gi,\n nameRegex: /class=\"[^\"]*product-title[^\"]*\"[^>]*>\\s*]*>([^<]+)<\\/a>/i,\n priceRegex: /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i,\n linkRegex: /class=\"[^\"]*product-title[^\"]*\"[^>]*>\\s*]*href=\"([^\"]+)\"/i,\n imageRegex: /]*class=\"[^\"]*img[^\"]*\"[^>]*src=\"([^\"]+)\"/i\n },\n primor: {\n productRegex: /]*class=\"[^\"]*product-item[^\"]*\"[^>]*>([\\s\\S]*?)<\\/div>/gi,\n nameRegex: /class=\"[^\"]*product-title[^\"]*\"[^>]*>([^<]+)<\\/[^>]+>/i,\n priceRegex: /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i,\n linkRegex: /]*href=\"([^\"]+)\"[^>]*>/i,\n imageRegex: /]*src=\"([^\"]+)\"[^>]*>/i\n },\n mifarma: {\n productRegex: /]*class=\"[^\"]*product-card[^\"]*\"[^>]*>([\\s\\S]*?)<\\/div>/gi,\n nameRegex: /class=\"[^\"]*product-name[^\"]*\"[^>]*>([^<]+)<\\/[^>]+>/i,\n priceRegex: /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i,\n linkRegex: /]*href=\"([^\"]+)\"[^>]*>/i,\n imageRegex: /]*src=\"([^\"]+)\"[^>]*>/i\n }\n};\n\nconst p = patterns[source] || patterns.promofarma;\nlet match;\n\nwhile ((match = p.productRegex.exec(html)) !== null) {\n const card = match[1];\n const name = p.nameRegex.exec(card)?.[1]?.trim();\n const priceStr = p.priceRegex.exec(card)?.[1]?.trim();\n const link = p.linkRegex.exec(card)?.[1];\n const image = p.imageRegex.exec(card)?.[1];\n \n if (name && name.length > 3) {\n const price = parseFloat(priceStr?.replace(/[^\\d.,]/g, '').replace(',', '.')) || 0;\n const baseUrl = {\n promofarma: 'https://www.promofarma.com',\n pharmarket: 'https://www.pharmarket.es',\n docmorris: 'https://www.docmorris.es',\n '1001farma': 'https://www.1001farma.net',\n primor: 'https://www.primor.eu',\n mifarma: 'https://www.mifarma.es'\n }[source];\n \n products.push({\n name: name.substring(0, 200),\n price,\n source_url: link?.startsWith('http') ? link : `${baseUrl}${link}`,\n image_url: image?.startsWith('http') ? image : (image ? `${baseUrl}${image}` : null),\n source,\n source_product_id: link?.match(/[\\/\\?].*?([\\w-]+)$/)?.[1] || `${source}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,\n brand: '',\n category: 'parapharmacy',\n available: true,\n scraped_at: new Date().toISOString()\n });\n }\n}\n\n// Deduplicate by name\nconst seen = new Set();\nconst unique = products.filter(p => {\n const key = p.name.toLowerCase();\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n});\n\nreturn unique.slice(0, 10).map(p => ({ json: p }));" + }, + "id": "extract-products", + "name": "Extract Products", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1320, 300] + }, + { + "parameters": { + "method": "POST", + "url": "http://parapharmacy-api:3002/api/products/bulk", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ products: $input.all().map(item => item.json) }) }}", + "options": {} + }, + "id": "send-to-api", + "name": "Send to API", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1540, 300] + }, + { + "parameters": {}, + "id": "done", + "name": "Done", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1760, 300] + } + ], + "connections": { + "Schedule (Every 3 days)": { + "main": [[{ "node": "Set Queries", "type": "main", "index": 0 }]] + }, + "Set Queries": { + "main": [[{ "node": "Split Queries", "type": "main", "index": 0 }]] + }, + "Split Queries": { + "main": [[{ "node": "Define Sources", "type": "main", "index": 0 }]] + }, + "Define Sources": { + "main": [[{ "node": "Scrape Source", "type": "main", "index": 0 }]] + }, + "Scrape Source": { + "main": [[{ "node": "Extract Products", "type": "main", "index": 0 }]] + }, + "Extract Products": { + "main": [[{ "node": "Send to API", "type": "main", "index": 0 }]] + }, + "Send to API": { + "main": [[{ "node": "Done", "type": "main", "index": 0 }]] + } + }, + "active": false, + "settings": { "executionOrder": "v1" }, + "tags": [{ "name": "parapharmacy" }, { "name": "scraper" }, { "name": "scheduled" }] +} diff --git a/n8n/workflows/parapharmacy-webhook-scraper.json b/n8n/workflows/parapharmacy-webhook-scraper.json new file mode 100644 index 0000000..c9bac10 --- /dev/null +++ b/n8n/workflows/parapharmacy-webhook-scraper.json @@ -0,0 +1,99 @@ +{ + "name": "Parapharmacy Manual Scraper (All Sources)", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "scrape-all", + "options": {} + }, + "id": "webhook", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [220, 300], + "webhookId": "scrape-all" + }, + { + "parameters": { + "jsCode": "const body = $input.first().json.body || {};\nconst queries = body.queries || 'crema hidratante,protector solar,vitaminas,paracetamol';\nconst sources = body.sources || ['promofarma', 'pharmarket', 'docmorris', '1001farma', 'primor', 'mifarma'];\n\nreturn [{ json: { queries, sources } }];" + }, + "id": "parse-input", + "name": "Parse Input", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [440, 300] + }, + { + "parameters": { + "jsCode": "const input = $input.first().json;\nconst queries = input.queries.split(',').map(q => q.trim());\nconst sources = input.sources;\n\nconst tasks = [];\nfor (const query of queries) {\n for (const source of sources) {\n const urls = {\n promofarma: `https://www.promofarma.com/es/search?q=${encodeURIComponent(query)}`,\n pharmarket: `https://www.pharmarket.es/catalogsearch/result/?q=${encodeURIComponent(query)}`,\n docmorris: `https://www.docmorris.es/search?query=${encodeURIComponent(query)}`,\n '1001farma': `https://www.1001farma.net/buscar?s=${encodeURIComponent(query)}`,\n primor: `https://www.primor.eu/search?s=${encodeURIComponent(query)}`,\n mifarma: `https://www.mifarma.es/buscador?q=${encodeURIComponent(query)}`\n };\n \n tasks.push({\n query,\n source,\n url: urls[source]\n });\n }\n}\n\nreturn tasks.map(t => ({ json: t }));" + }, + "id": "generate-tasks", + "name": "Generate Tasks", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [660, 300] + }, + { + "parameters": { + "method": "GET", + "url": "={{ $json.url }}", + "options": { + "timeout": 30000 + } + }, + "id": "scrape", + "name": "Scrape", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [880, 300] + }, + { + "parameters": { + "jsCode": "const data = $input.first().json;\nconst html = data.data || '';\nconst source = data.source;\nconst query = data.query;\n\nconst products = [];\n\n// Generic extraction\nconst cardRegex = /<(?:div|article|li)[^>]*class=\"[^\"]*(?:product|item)[^\"]*\"[^>]*>([\\s\\S]*?)<\\/(?:div|article|li)>/gi;\nconst nameRegex = /<(?:h[23]|a|span)[^>]*class=\"[^\"]*(?:name|title)[^\"]*\"[^>]*>([^<]+)<\\/[^>]+>/i;\nconst priceRegex = /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i;\nconst linkRegex = /]*href=\"(https?:\\/\\/[^\"]+)\"/i;\nconst imageRegex = /]*src=\"(https?:\\/\\/[^\"\\.]+\\.(?:jpg|jpeg|png|webp)[^\"]*)\"/i;\n\nlet match;\nwhile ((match = cardRegex.exec(html)) !== null) {\n const card = match[1];\n const name = nameRegex.exec(card)?.[1]?.trim();\n const priceStr = priceRegex.exec(card)?.[1]?.trim();\n const link = linkRegex.exec(card)?.[1];\n const image = imageRegex.exec(card)?.[1];\n \n if (name && name.length > 3 && name.length < 200) {\n const price = parseFloat(priceStr?.replace(/[^\\d.,]/g, '').replace(',', '.')) || 0;\n if (price > 0 && price < 1000) {\n products.push({\n name: name.substring(0, 200),\n price,\n source_url: link || `https://${source}.com`,\n image_url: image || null,\n source,\n source_product_id: `${source}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`,\n brand: '',\n category: 'parapharmacy',\n available: true,\n scraped_at: new Date().toISOString()\n });\n }\n }\n}\n\n// Deduplicate\nconst seen = new Set();\nconst unique = products.filter(p => {\n const key = `${source}:${p.name.toLowerCase()}`;\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n});\n\nreturn unique.slice(0, 5).map(p => ({ json: p }));" + }, + "id": "extract", + "name": "Extract Products", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1100, 300] + }, + { + "parameters": { + "method": "POST", + "url": "http://parapharmacy-api:3002/api/products/bulk", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ products: $input.all().map(i => i.json) }) }}", + "options": {} + }, + "id": "send-bulk", + "name": "Send to API", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1320, 300] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { success: true, message: 'Scraping completed', products: $input.all().length } }}" + }, + "id": "respond", + "name": "Respond", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [1540, 300] + } + ], + "connections": { + "Webhook": { "main": [[{ "node": "Parse Input", "type": "main", "index": 0 }]] }, + "Parse Input": { "main": [[{ "node": "Generate Tasks", "type": "main", "index": 0 }]] }, + "Generate Tasks": { "main": [[{ "node": "Scrape", "type": "main", "index": 0 }]] }, + "Scrape": { "main": [[{ "node": "Extract Products", "type": "main", "index": 0 }]] }, + "Extract Products": { "main": [[{ "node": "Send to API", "type": "main", "index": 0 }]] }, + "Send to API": { "main": [[{ "node": "Respond", "type": "main", "index": 0 }]] } + }, + "active": true, + "settings": { "executionOrder": "v1" }, + "tags": [{ "name": "parapharmacy" }, { "name": "scraper" }, { "name": "webhook" }] +} diff --git a/package-lock.json b/package-lock.json index d09a9c5..cd11d50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1445,6 +1445,24 @@ "node": ">=8" } }, + "apps/parapharmacy-api": { + "name": "farmafinder-parapharmacy-api", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.0", + "cors": "^2.8.5", + "express": "^4.18.2", + "express-rate-limit": "^8.5.2", + "mongoose": "^8.8.0", + "morgan": "^1.10.0", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.0" + }, + "devDependencies": { + "jest": "^29.7.0", + "supertest": "^7.2.2" + } + }, "apps/scraper": { "version": "1.0.0", "license": "ISC", @@ -1475,6 +1493,50 @@ "ajv": ">=8" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -5102,7 +5164,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "dev": true, "engines": { "node": ">=18" } @@ -5988,6 +6049,14 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.12.tgz", + "integrity": "sha512-QAfAMwNgnYxZ2C6D1HgeP7Gc4i/uvJRim415PCIL9ptRxWMNbWeLBYb2/9R4pGKny/s1FVu2JA2cxCUBUOggrA==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -12631,6 +12700,12 @@ "win32" ] }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -13174,6 +13249,11 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + }, "node_modules/@types/memcached": { "version": "2.2.10", "resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.10.tgz", @@ -13282,6 +13362,19 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "dev": true }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -13584,7 +13677,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13596,6 +13688,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/anser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", @@ -14278,6 +14383,22 @@ "node": ">=6.0.0" } }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/basic-ftp": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", @@ -14461,6 +14582,14 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -14724,6 +14853,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -15843,6 +15977,17 @@ "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz", "integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==" }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -16648,6 +16793,10 @@ "resolved": "apps/frontend", "link": true }, + "node_modules/farmafinder-parapharmacy-api": { + "resolved": "apps/parapharmacy-api", + "link": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -16674,7 +16823,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", - "dev": true, "funding": [ { "type": "github", @@ -16927,7 +17075,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" @@ -16943,7 +17090,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -18498,7 +18644,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "dev": true, "dependencies": { "@isaacs/cliui": "^9.0.0" }, @@ -20256,8 +20401,7 @@ "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/json5": { "version": "2.2.3", @@ -20310,6 +20454,14 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -20661,6 +20813,11 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -20875,6 +21032,11 @@ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, "node_modules/merge-deep": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", @@ -21670,6 +21832,162 @@ "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==" }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.1.tgz", + "integrity": "sha512-UpHBA0l5kHyKJQFjmBaFYQFo5sgz1DK0TRqDkOyBLYbqiIbKKhIvBpHWBXqeo0rgW4kGI1UhhAw+kTQZoj1BdA==", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.20.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongoose/node_modules/gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongoose/node_modules/mongodb": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/morgan": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", + "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.4.1", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -22254,6 +22572,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "peer": true + }, "node_modules/opencollective-postinstall": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", @@ -23121,7 +23445,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "engines": { "node": ">=6" } @@ -23782,7 +24105,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -24381,6 +24703,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -24629,6 +24956,14 @@ "source-map": "^0.6.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/split-on-first": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", @@ -25211,6 +25546,119 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.3.0.tgz", + "integrity": "sha512-I+iQjVGV3t28pOkQUJv2MncthvOtkEactOn8R76SvSYhxgtIn7FoqfDHwQaN+GBnQdXQLrhgDXseKitmJcHMsA==", + "dependencies": { + "@apidevtools/swagger-parser": "^12.1.0", + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "11.1.0", + "lodash.mergewith": "^4.6.2", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/swagger-jsdoc/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.8", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", + "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -25570,7 +26018,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, "dependencies": { "punycode": "^2.3.1" }, @@ -26493,7 +26940,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, "engines": { "node": ">=12" } @@ -26541,7 +26987,6 @@ "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0"