From b34657c78fbb09b8636c901dfd76acbf3e01d31f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 12:26:54 +0200 Subject: [PATCH 01/25] feat: add parapharmacy API with MongoDB and N8N setup - Create parapharmacy-api microservice with Express + Swagger + MongoDB - Add product model with full-text search and deduplication - Add CRUD endpoints and bulk upsert for scrapers - Update docker-compose.yml with MongoDB, N8N, and parapharmacy-api services - Add proxy endpoints in FarmaFinder backend for parapharmacy - Update frontend services to use parapharmacy API - Add Swagger documentation at /api/docs --- apps/backend/server.js | 59 +++ apps/frontend-mobile/app/(tabs)/search.tsx | 8 +- apps/frontend-mobile/services/products.ts | 70 +++- apps/frontend/src/views/PublicView.jsx | 29 +- apps/frontend/src/views/SearchView.jsx | 36 +- apps/parapharmacy-api/.env.example | 13 + apps/parapharmacy-api/Dockerfile | 22 ++ apps/parapharmacy-api/README.md | 120 ++++++ apps/parapharmacy-api/package.json | 26 ++ apps/parapharmacy-api/src/config.js | 22 ++ apps/parapharmacy-api/src/models/Product.js | 172 ++++++++ apps/parapharmacy-api/src/routes/products.js | 392 +++++++++++++++++++ apps/parapharmacy-api/src/server.js | 130 ++++++ docker-compose.yml | 56 +++ package.json | 1 + 15 files changed, 1129 insertions(+), 27 deletions(-) create mode 100644 apps/parapharmacy-api/.env.example create mode 100644 apps/parapharmacy-api/Dockerfile create mode 100644 apps/parapharmacy-api/README.md create mode 100644 apps/parapharmacy-api/package.json create mode 100644 apps/parapharmacy-api/src/config.js create mode 100644 apps/parapharmacy-api/src/models/Product.js create mode 100644 apps/parapharmacy-api/src/routes/products.js create mode 100644 apps/parapharmacy-api/src/server.js diff --git a/apps/backend/server.js b/apps/backend/server.js index 0aff672..1968843 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -778,6 +778,65 @@ app.get('/api/products/:source/:productId/pharmacies', async (req, res) => { } }); +// ========== PARAPHARMACY PROXY ========== + +const PARAPHARMACY_API_URL = process.env.PARAPHARMACY_API_URL || 'http://localhost:3002'; + +// Search parapharmacy products (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/search', searchLimiter, async (req, res) => { + try { + const { q, category, brand, page = 1, limit = 20 } = req.query; + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (category) params.set('category', category); + if (brand) params.set('brand', brand); + params.set('page', page); + params.set('limit', limit); + + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/search?${params}`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Search error:', error.message); + res.status(500).json({ error: 'Error searching parapharmacy products' }); + } +}); + +// Get parapharmacy product details (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/:id', async (req, res) => { + try { + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/${req.params.id}`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Detail error:', error.message); + if (error.response?.status === 404) { + return res.status(404).json({ error: 'Product not found' }); + } + res.status(500).json({ error: 'Error fetching product details' }); + } +}); + +// Get parapharmacy categories (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/categories', async (req, res) => { + try { + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/categories`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Categories error:', error.message); + res.status(500).json({ error: 'Error fetching categories' }); + } +}); + +// Get parapharmacy brands (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/brands', async (req, res) => { + try { + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/brands`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Brands error:', error.message); + res.status(500).json({ error: 'Error fetching brands' }); + } +}); + // ========== AUTHENTICATION MIDDLEWARE ========== // Middleware to check if user is authenticated diff --git a/apps/frontend-mobile/app/(tabs)/search.tsx b/apps/frontend-mobile/app/(tabs)/search.tsx index dfb11d5..8ce000b 100644 --- a/apps/frontend-mobile/app/(tabs)/search.tsx +++ b/apps/frontend-mobile/app/(tabs)/search.tsx @@ -9,7 +9,7 @@ import { useDebounce } from '../../hooks/useDebounce'; import { useAuth } from '../../hooks/useAuth'; import { useRecentSearches } from '../../hooks/useRecentSearches'; import { searchMedicines } from '../../services/medicines'; -import { searchProducts, Product } from '../../services/products'; +import { searchParapharmacy, Product } from '../../services/products'; import { useThemeContext } from '../../components/ThemeProvider'; import { spacing, borderRadius } from '../../constants/theme'; import { Medicine } from '../../types'; @@ -78,10 +78,10 @@ export default function SearchScreen() { const fetchProducts = async () => { try { - const productResults = await searchProducts(debouncedQuery); - setProducts(productResults); + const response = await searchParapharmacy(debouncedQuery); + setProducts(response.results || []); } catch (err) { - console.error('Product search error:', err); + console.error('Parapharmacy search error:', err); } }; diff --git a/apps/frontend-mobile/services/products.ts b/apps/frontend-mobile/services/products.ts index 32faab7..078a386 100644 --- a/apps/frontend-mobile/services/products.ts +++ b/apps/frontend-mobile/services/products.ts @@ -2,11 +2,19 @@ import api from './api'; export interface Product { id: string; - source: 'cima' | 'openfoodfacts'; + _id?: string; + source: 'cima' | 'openfoodfacts' | 'promofarma' | 'pharmarket' | 'docmorris' | '1001farma' | 'primor' | 'mifarma'; name: string; brand: string; category: string; + subcategory?: string; image_url: string | null; + source_url?: string; + price?: number; + original_price?: number; + available?: boolean; + rating?: number; + review_count?: number; active_ingredient?: string; dosage?: string; form?: string; @@ -23,7 +31,9 @@ export interface Product { export interface ProductSearchResponse { results: Product[]; total: number; - sources: { + page?: number; + pages?: number; + sources?: { cima: number; openfoodfacts: number; }; @@ -42,6 +52,62 @@ export async function searchProducts(query: string): Promise { } } +export async function searchParapharmacy(query: string, options?: { + category?: string; + brand?: string; + page?: number; + limit?: number; +}): Promise { + if (!query || query.trim().length < 2) { + return { results: [], total: 0 }; + } + try { + const params: Record = { q: query }; + if (options?.category) params.category = options.category; + if (options?.brand) params.brand = options.brand; + if (options?.page) params.page = options.page; + if (options?.limit) params.limit = options.limit; + + const { data } = await api.get('/products/parapharmacy/search', { + params + }); + return data; + } catch (error) { + console.error('[Parapharmacy] Search error:', error); + return { results: [], total: 0 }; + } +} + +export async function getParapharmacyProduct(id: string): Promise { + try { + const { data } = await api.get(`/products/parapharmacy/${id}`); + return data; + } catch (error) { + console.error('[Parapharmacy] Detail error:', error); + return null; + } +} + +export async function getParapharmacyCategories(): Promise { + try { + const { data } = await api.get('/products/parapharmacy/categories'); + return data || []; + } catch (error) { + console.error('[Parapharmacy] Categories error:', error); + return []; + } +} + +export async function getParapharmacyBrands(): Promise { + try { + const { data } = await api.get('/products/parapharmacy/brands'); + return data || []; + } catch (error) { + console.error('[Parapharmacy] Brands error:', error); + return []; + } +} + export async function getProduct(source: string, id: string): Promise { try { const { data } = await api.get(`/products/${source}/${id}`); diff --git a/apps/frontend/src/views/PublicView.jsx b/apps/frontend/src/views/PublicView.jsx index 22dd84c..6be6bc5 100644 --- a/apps/frontend/src/views/PublicView.jsx +++ b/apps/frontend/src/views/PublicView.jsx @@ -55,21 +55,32 @@ function PublicView({ setLoading(false); } - try { - const productsResponse = await fetch(`/api/products/search?q=${encodeURIComponent(searchQuery)}`); - if (productsResponse.ok) { - const productsData = await productsResponse.json(); - setProducts(productsData.results || []); - } - } catch (err) { - console.error('Product search error:', err); - } + // Only search medications from CIMA }; const timeoutId = setTimeout(searchMedicines, 300); return () => clearTimeout(timeoutId); }, [searchQuery]); + // Search parapharmacy when user switches to Parafarmacia tab + useEffect(() => { + if (searchMode !== 'products' || searchQuery.trim().length < 2) return; + + const searchProducts = async () => { + try { + const response = await fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(searchQuery)}`); + if (response.ok) { + const data = await response.json(); + setProducts(data.results || []); + } + } catch (err) { + console.error('Parapharmacy search error:', err); + } + }; + + searchProducts(); + }, [searchMode, searchQuery]); + useEffect(() => { const fetchPharmacies = async () => { if (!selectedMedicine) { diff --git a/apps/frontend/src/views/SearchView.jsx b/apps/frontend/src/views/SearchView.jsx index 9aa5f63..d711f51 100644 --- a/apps/frontend/src/views/SearchView.jsx +++ b/apps/frontend/src/views/SearchView.jsx @@ -88,24 +88,16 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate const query = searchQuery.trim(); try { - // Run both searches in parallel for speed - const [medicinesRes, productsRes] = await Promise.allSettled([ - fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`), - fetch(`/api/products/search?q=${encodeURIComponent(query)}`), - ]); + // Only search medications from CIMA + const medicinesRes = await fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`); // Only update if this search is still the current one if (query !== searchQuery.trim()) return; - if (medicinesRes.status === 'fulfilled' && medicinesRes.value.ok) { - const medicinesData = await medicinesRes.value.json(); + if (medicinesRes.ok) { + const medicinesData = await medicinesRes.json(); setMedicines(medicinesData); } - - if (productsRes.status === 'fulfilled' && productsRes.value.ok) { - const productsData = await productsRes.value.json(); - setProducts(productsData.results || []); - } } catch (error) { console.error('Search error:', error); } finally { @@ -116,6 +108,26 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate return () => clearTimeout(timeoutId); }, [searchQuery]); + // Search parapharmacy when user switches to Parafarmacia tab + useEffect(() => { + if (searchMode !== 'products' || searchQuery.trim().length < 2) return; + + const searchProducts = async () => { + const query = searchQuery.trim(); + try { + const response = await fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(query)}`); + if (response.ok) { + const data = await response.json(); + setProducts(data.results || []); + } + } catch (error) { + console.error('Parapharmacy search error:', error); + } + }; + + searchProducts(); + }, [searchMode, searchQuery]); + useEffect(() => { const fetchPharmacies = async () => { if (!selectedMedicine) { diff --git a/apps/parapharmacy-api/.env.example b/apps/parapharmacy-api/.env.example new file mode 100644 index 0000000..3159bd3 --- /dev/null +++ b/apps/parapharmacy-api/.env.example @@ -0,0 +1,13 @@ +# Parapharmacy API Configuration +PORT=3002 +NODE_ENV=development + +# MongoDB +MONGODB_URI=mongodb://localhost:27017/parapharmacy + +# CORS +CORS_ORIGIN=http://localhost:3000 + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX=100 diff --git a/apps/parapharmacy-api/Dockerfile b/apps/parapharmacy-api/Dockerfile new file mode 100644 index 0000000..2a630c0 --- /dev/null +++ b/apps/parapharmacy-api/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy source code +COPY src/ ./src/ + +# Expose port +EXPOSE 3002 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3002/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })" + +# Start server +CMD ["node", "--env-file-if-exists=.env", "src/server.js"] diff --git a/apps/parapharmacy-api/README.md b/apps/parapharmacy-api/README.md new file mode 100644 index 0000000..c65cb77 --- /dev/null +++ b/apps/parapharmacy-api/README.md @@ -0,0 +1,120 @@ +# FarmaFinder Parapharmacy API + +Microservicio para productos de parafarmacia de múltiples tiendas españolas. + +## Arquitectura + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ N8N Workflows │────▶│ Parapharmacy │────▶│ MongoDB │ +│ (Scraper) │ │ API (Express) │ │ │ +└─────────────────┘ └────────┬─────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ FarmaFinder │ + │ Backend │ + └─────────────────┘ +``` + +## Endpoints + +| Método | Ruta | Descripción | +|--------|------|-------------| +| GET | `/api/products/search?q=term` | Buscar productos | +| GET | `/api/products/:id` | Detalle de producto | +| GET | `/api/products` | Listar productos | +| POST | `/api/products` | Crear producto | +| POST | `/api/products/bulk` | Crear/actualizar múltiples | +| PUT | `/api/products/:id` | Actualizar producto | +| DELETE | `/api/products/:id` | Eliminar producto | +| GET | `/api/products/categories` | Listar categorías | +| GET | `/api/products/brands` | Listar marcas | +| GET | `/api/sources` | Fuentes configuradas | +| GET | `/api/health` | Health check | +| GET | `/api/docs` | Swagger UI | + +## Desarrollo + +### Requisitos + +- Node.js 20+ +- MongoDB 7+ + +### Instalación + +```bash +cd apps/parapharmacy-api +npm install +``` + +### Ejecutar + +```bash +# Desarrollo +npm run dev + +# Producción +npm start +``` + +### Variables de Entorno + +```bash +PORT=3002 +MONGODB_URI=mongodb://localhost:27017/parapharmacy +CORS_ORIGIN=http://localhost:3000 +``` + +## Docker + +```bash +# Construir imagen +docker build -t farmafinder-parapharmacy-api . + +# Ejecutar +docker run -p 3002:3002 -e MONGODB_URI=mongodb://host:27017/parapharmacy farmafinder-parapharmacy-api +``` + +## Fuentes de Scraping + +| Fuente | URL | Estado | +|--------|-----|--------| +| Promofarma | promofarma.com | ✅ | +| Pharmarket | pharmarket.es | 🔄 Pendiente | +| DocMorris | docmorris.es | 🔄 Pendiente | +| 1001Farma | 1001farma.net | 🔄 Pendiente | +| Primor | primor.eu | 🔄 Pendiente | +| MiFarma | mifarma.es | 🔄 Pendiente | + +## Schema MongoDB + +```javascript +{ + name: String, // Nombre del producto + brand: String, // Marca + category: String, // Categoría + subcategory: String, // Subcategoría + description: String, // Descripción + image_url: String, // URL imagen + source_url: String, // URL tienda original + price: Number, // Precio actual + original_price: Number, // Precio anterior + source: String, // Fuente + source_product_id: String, + available: Boolean, + rating: Number, + review_count: Number, + scraped_at: Date +} +``` + +## Tests + +```bash +npm test +``` + +## Swagger + +Documentación disponible en: `http://localhost:3002/api/docs` diff --git a/apps/parapharmacy-api/package.json b/apps/parapharmacy-api/package.json new file mode 100644 index 0000000..7833417 --- /dev/null +++ b/apps/parapharmacy-api/package.json @@ -0,0 +1,26 @@ +{ + "name": "farmafinder-parapharmacy-api", + "version": "1.0.0", + "description": "Parapharmacy products API for FarmaFinder", + "main": "src/server.js", + "type": "module", + "scripts": { + "start": "node --env-file-if-exists=.env src/server.js", + "dev": "node --env-file-if-exists=.env --watch src/server.js", + "test": "NODE_OPTIONS='--experimental-vm-modules' npx jest --ci --forceExit --forceExitTimeout=30000" + }, + "dependencies": { + "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", + "axios": "^1.6.0" + }, + "devDependencies": { + "jest": "^29.7.0", + "supertest": "^7.2.2" + } +} diff --git a/apps/parapharmacy-api/src/config.js b/apps/parapharmacy-api/src/config.js new file mode 100644 index 0000000..4f127be --- /dev/null +++ b/apps/parapharmacy-api/src/config.js @@ -0,0 +1,22 @@ +export const config = { + port: process.env.PORT || 3002, + mongodb: { + uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/parapharmacy', + }, + cors: { + origin: process.env.CORS_ORIGIN || 'http://localhost:3000', + credentials: true, + }, + rateLimit: { + windowMs: 60 * 1000, // 1 minute + max: 100, // limit each IP to 100 requests per windowMs + }, + sources: [ + { id: 'promofarma', name: 'Promofarma', url: 'https://www.promofarma.com' }, + { id: 'pharmarket', name: 'Pharmarket', url: 'https://www.pharmarket.es' }, + { id: 'docmorris', name: 'DocMorris', url: 'https://www.docmorris.es' }, + { id: '1001farma', name: '1001Farma', url: 'https://www.1001farma.net' }, + { id: 'primor', name: 'Primor', url: 'https://www.primor.eu' }, + { id: 'mifarma', name: 'MiFarma', url: 'https://www.mifarma.es' }, + ], +}; diff --git a/apps/parapharmacy-api/src/models/Product.js b/apps/parapharmacy-api/src/models/Product.js new file mode 100644 index 0000000..2386c9c --- /dev/null +++ b/apps/parapharmacy-api/src/models/Product.js @@ -0,0 +1,172 @@ +import mongoose from 'mongoose'; + +const productSchema = new mongoose.Schema({ + // Product data + name: { + type: String, + required: true, + index: true, + }, + brand: { + type: String, + index: true, + }, + category: { + type: String, + index: true, + }, + subcategory: { + type: String, + }, + description: { + type: String, + }, + image_url: { + type: String, + }, + source_url: { + type: String, + required: true, + }, + + // Prices + price: { + type: Number, + required: true, + }, + original_price: { + type: Number, + }, + currency: { + type: String, + default: 'EUR', + }, + + // Source + source: { + type: String, + required: true, + enum: ['promofarma', 'pharmarket', 'docmorris', '1001farma', 'primor', 'mifarma'], + index: true, + }, + source_product_id: { + type: String, + required: true, + }, + + // Metadata + available: { + type: Boolean, + default: true, + }, + rating: { + type: Number, + min: 0, + max: 5, + }, + review_count: { + type: Number, + default: 0, + }, + + // Search terms (for full-text search) + search_terms: [{ + type: String, + }], + + // Timestamps + scraped_at: { + type: Date, + default: Date.now, + }, +}, { + timestamps: true, +}); + +// Compound index for deduplication +productSchema.index({ source: 1, source_product_id: 1 }, { unique: true }); + +// Text index for search +productSchema.index({ + name: 'text', + brand: 'text', + category: 'text', + description: 'text', + search_terms: 'text', +}, { + weights: { + name: 10, + brand: 5, + category: 3, + description: 2, + search_terms: 1, + }, +}); + +// Pre-save hook to populate search_terms +productSchema.pre('save', function (next) { + const terms = new Set(); + + if (this.name) this.name.split(/\s+/).forEach(t => terms.add(t.toLowerCase())); + if (this.brand) this.brand.split(/\s+/).forEach(t => terms.add(t.toLowerCase())); + if (this.category) this.category.split(/\s+/).forEach(t => terms.add(t.toLowerCase())); + if (this.subcategory) this.subcategory.split(/\s+/).forEach(t => terms.add(t.toLowerCase())); + + this.search_terms = Array.from(terms); + next(); +}); + +// Static method to find or create by source +productSchema.statics.findBySource = function (source, sourceProductId) { + return this.findOne({ source, source_product_id: sourceProductId }); +}; + +// Static method to upsert product +productSchema.statics.upsert = async function (data) { + const existing = await this.findBySource(data.source, data.source_product_id); + + if (existing) { + // Update existing product + Object.assign(existing, data, { scraped_at: new Date() }); + return existing.save(); + } + + // Create new product + return this.create(data); +}; + +// Static method to search products +productSchema.statics.search = async function (query, options = {}) { + const { page = 1, limit = 20, category, brand } = options; + + const filter = {}; + + if (category) filter.category = category; + if (brand) filter.brand = brand; + + if (query) { + filter.$text = { $search: query }; + } + + const skip = (page - 1) * limit; + + const [results, total] = await Promise.all([ + this.find(filter) + .sort(query ? { score: { $meta: 'textScore' } } : { scraped_at: -1 }) + .skip(skip) + .limit(limit) + .lean(), + this.countDocuments(filter), + ]); + + return { + results, + total, + page, + pages: Math.ceil(total / limit), + }; +}; + +const Product = mongoose.model('Product', productSchema); + +export default Product; diff --git a/apps/parapharmacy-api/src/routes/products.js b/apps/parapharmacy-api/src/routes/products.js new file mode 100644 index 0000000..05e9a3d --- /dev/null +++ b/apps/parapharmacy-api/src/routes/products.js @@ -0,0 +1,392 @@ +import { Router } from 'express'; +import Product from '../models/Product.js'; + +const router = Router(); + +/** + * @swagger + * /api/products/search: + * get: + * summary: Search parapharmacy products + * tags: [Products] + * parameters: + * - in: query + * name: q + * schema: + * type: string + * description: Search query + * - in: query + * name: category + * schema: + * type: string + * description: Filter by category + * - in: query + * name: brand + * schema: + * type: string + * description: Filter by brand + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * responses: + * 200: + * description: Search results + */ +router.get('/search', async (req, res) => { + try { + const { q, category, brand, page = 1, limit = 20 } = req.query; + + const result = await Product.search(q, { + page: parseInt(page), + limit: Math.min(parseInt(limit), 50), + category, + brand, + }); + + res.json(result); + } catch (error) { + console.error('[Products] Search error:', error); + res.status(500).json({ error: 'Error searching products' }); + } +}); + +/** + * @swagger + * /api/products/categories: + * get: + * summary: Get all categories + * tags: [Products] + * responses: + * 200: + * description: List of categories + */ +router.get('/categories', async (req, res) => { + try { + const categories = await Product.distinct('category'); + res.json(categories.filter(Boolean).sort()); + } catch (error) { + console.error('[Products] Categories error:', error); + res.status(500).json({ error: 'Error fetching categories' }); + } +}); + +/** + * @swagger + * /api/products/brands: + * get: + * summary: Get all brands + * tags: [Products] + * responses: + * 200: + * description: List of brands + */ +router.get('/brands', async (req, res) => { + try { + const brands = await Product.distinct('brand'); + res.json(brands.filter(Boolean).sort()); + } catch (error) { + console.error('[Products] Brands error:', error); + res.status(500).json({ error: 'Error fetching brands' }); + } +}); + +/** + * @swagger + * /api/products: + * get: + * summary: List all products + * tags: [Products] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * - in: query + * name: source + * schema: + * type: string + * description: Filter by source + * responses: + * 200: + * description: List of products + */ +router.get('/', async (req, res) => { + try { + const { page = 1, limit = 20, source } = req.query; + + const filter = {}; + if (source) filter.source = source; + + const skip = (parseInt(page) - 1) * parseInt(limit); + + const [products, total] = await Promise.all([ + Product.find(filter) + .sort({ scraped_at: -1 }) + .skip(skip) + .limit(Math.min(parseInt(limit), 50)) + .lean(), + Product.countDocuments(filter), + ]); + + res.json({ + results: products, + total, + page: parseInt(page), + pages: Math.ceil(total / parseInt(limit)), + }); + } catch (error) { + console.error('[Products] List error:', error); + res.status(500).json({ error: 'Error fetching products' }); + } +}); + +/** + * @swagger + * /api/products/{id}: + * get: + * summary: Get product by ID + * tags: [Products] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Product details + * 404: + * description: Product not found + */ +router.get('/:id', async (req, res) => { + try { + const product = await Product.findById(req.params.id).lean(); + + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + + res.json(product); + } catch (error) { + console.error('[Products] Get error:', error); + res.status(500).json({ error: 'Error fetching product' }); + } +}); + +/** + * @swagger + * /api/products: + * post: + * summary: Create or update a product (for scraper) + * tags: [Products] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - price + * - source + * - source_product_id + * - source_url + * responses: + * 201: + * description: Product created/updated + */ +router.post('/', async (req, res) => { + try { + const { + name, + brand, + category, + subcategory, + description, + image_url, + source_url, + price, + original_price, + source, + source_product_id, + available, + rating, + review_count, + } = req.body; + + if (!name || !price || !source || !source_product_id || !source_url) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const product = await Product.upsert({ + name, + brand, + category, + subcategory, + description, + image_url, + source_url, + price, + original_price, + source, + source_product_id, + available: available !== false, + rating, + review_count, + scraped_at: new Date(), + }); + + res.status(201).json(product); + } catch (error) { + console.error('[Products] Create error:', error); + res.status(500).json({ error: 'Error creating product' }); + } +}); + +/** + * @swagger + * /api/products/bulk: + * post: + * summary: Bulk upsert products (for scraper) + * tags: [Products] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - products + * properties: + * products: + * type: array + * responses: + * 200: + * description: Upsert results + */ +router.post('/bulk', async (req, res) => { + try { + const { products } = req.body; + + if (!Array.isArray(products)) { + return res.status(400).json({ error: 'products must be an array' }); + } + + const results = { + created: 0, + updated: 0, + errors: 0, + }; + + for (const productData of products) { + try { + const existing = await Product.findBySource( + productData.source, + productData.source_product_id + ); + + if (existing) { + Object.assign(existing, productData, { scraped_at: new Date() }); + await existing.save(); + results.updated++; + } else { + await Product.create(productData); + results.created++; + } + } catch (err) { + console.error('[Products] Bulk upsert error:', err.message); + results.errors++; + } + } + + res.json(results); + } catch (error) { + console.error('[Products] Bulk error:', error); + res.status(500).json({ error: 'Error in bulk upsert' }); + } +}); + +/** + * @swagger + * /api/products/{id}: + * put: + * summary: Update a product + * tags: [Products] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Updated product + * 404: + * description: Product not found + */ +router.put('/:id', async (req, res) => { + try { + const product = await Product.findByIdAndUpdate( + req.params.id, + { ...req.body, updated_at: new Date() }, + { new: true } + ); + + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + + res.json(product); + } catch (error) { + console.error('[Products] Update error:', error); + res.status(500).json({ error: 'Error updating product' }); + } +}); + +/** + * @swagger + * /api/products/{id}: + * delete: + * summary: Delete a product + * tags: [Products] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 204: + * description: Product deleted + * 404: + * description: Product not found + */ +router.delete('/:id', async (req, res) => { + try { + const product = await Product.findByIdAndDelete(req.params.id); + + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + + res.status(204).end(); + } catch (error) { + console.error('[Products] Delete error:', error); + res.status(500).json({ error: 'Error deleting product' }); + } +}); + +export default router; diff --git a/apps/parapharmacy-api/src/server.js b/apps/parapharmacy-api/src/server.js new file mode 100644 index 0000000..92664d4 --- /dev/null +++ b/apps/parapharmacy-api/src/server.js @@ -0,0 +1,130 @@ +import express from 'express'; +import cors from 'cors'; +import mongoose from 'mongoose'; +import morgan from 'morgan'; +import rateLimit from 'express-rate-limit'; +import swaggerJsdoc from 'swagger-jsdoc'; +import swaggerUi from 'swagger-ui-express'; +import { config } from './config.js'; +import productsRouter from './routes/products.js'; + +const app = express(); + +// Swagger configuration +const swaggerOptions = { + definition: { + openapi: '3.0.0', + info: { + title: 'FarmaFinder Parapharmacy API', + version: '1.0.0', + description: 'API for parapharmacy products from multiple Spanish pharmacies', + }, + servers: [ + { + url: `http://localhost:${config.port}`, + description: 'Development server', + }, + ], + components: { + schemas: { + Product: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + brand: { type: 'string' }, + category: { type: 'string' }, + price: { type: 'number' }, + source: { type: 'string' }, + }, + }, + }, + }, + }, + apis: ['./src/routes/*.js'], +}; + +const swaggerSpec = swaggerJsdoc(swaggerOptions); + +// Middleware +app.use(cors(config.cors)); +app.use(express.json({ limit: '10mb' })); +app.use(morgan('combined')); + +// Rate limiting +const limiter = rateLimit({ + windowMs: config.rateLimit.windowMs, + max: config.rateLimit.max, + standardHeaders: true, + legacyHeaders: false, +}); +app.use(limiter); + +// Swagger UI +app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + explorer: true, + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'FarmaFinder Parapharmacy API', +})); + +// Routes +app.use('/api/products', productsRouter); + +// Health check +app.get('/api/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + mongodb: mongoose.connection.readyState === 1 ? 'connected' : 'disconnected', + }); +}); + +// Sources info +app.get('/api/sources', (req, res) => { + res.json(config.sources); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ error: 'Not found' }); +}); + +// Connect to MongoDB and start server +async function start() { + try { + console.log(`[Parapharmacy API] Connecting to MongoDB: ${config.mongodb.uri}`); + await mongoose.connect(config.mongodb.uri); + console.log('[Parapharmacy API] Connected to MongoDB'); + + app.listen(config.port, () => { + console.log(`[Parapharmacy API] Server running on port ${config.port}`); + console.log(`[Parapharmacy API] Swagger docs: http://localhost:${config.port}/api/docs`); + }); + } catch (error) { + console.error('[Parapharmacy API] Failed to start:', error); + process.exit(1); + } +} + +// Handle graceful shutdown +process.on('SIGTERM', async () => { + console.log('[Parapharmacy API] SIGTERM received, shutting down...'); + await mongoose.disconnect(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('[Parapharmacy API] SIGINT received, shutting down...'); + await mongoose.disconnect(); + process.exit(0); +}); + +start(); + +export default app; diff --git a/docker-compose.yml b/docker-compose.yml index 380331e..d9ce987 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,62 @@ services: depends_on: - postgres + # --- Parapharmacy API --- + parapharmacy-api: + image: git.hacecalor.net/ichitux/farmafinder-parapharmacy-api:latest + build: + context: . + dockerfile: apps/parapharmacy-api/Dockerfile + restart: unless-stopped + ports: + - "3002:3002" + environment: + PORT: "3002" + NODE_ENV: production + MONGODB_URI: mongodb://mongodb:27017/parapharmacy + CORS_ORIGIN: http://localhost:4000 + depends_on: + - mongodb + + # --- MongoDB for Parapharmacy --- + mongodb: + image: mongo:7 + restart: unless-stopped + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + + # --- N8N Workflow Automation --- + n8n: + image: n8nio/n8n:latest + restart: unless-stopped + ports: + - "5678:5678" + environment: + N8N_BASIC_AUTH_ACTIVE: "true" + N8N_BASIC_AUTH_USER: ${N8N_USER:-admin} + N8N_BASIC_AUTH_PASSWORD: ${N8N_PASSWORD:-change-me} + DB_TYPE: postgresdb + DB_POSTGRESDB_HOST: postgres + DB_POSTGRESDB_DATABASE: farmafinder + DB_POSTGRESDB_USER: farmafinder + DB_POSTGRESDB_PASSWORD: ${PG_PASSWORD:-change-me-in-production} + N8N_HOST: localhost + N8N_PORT: 5678 + N8N_PROTOCOL: http + volumes: + - n8n_data:/home/node/.n8n + depends_on: + - postgres + + # --- Parapharmacy Backend connection --- + backend: + environment: + PARAPHARMACY_API_URL: http://parapharmacy-api:3002 + volumes: backend_data: postgres_data: + mongodb_data: + n8n_data: diff --git a/package.json b/package.json index faaa787..1667d5e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev": "turbo run dev", "dev:backend": "turbo run dev --filter=farma-clic-backend", "dev:frontend": "turbo run dev --filter=farma-clic-frontend", + "dev:parapharmacy": "turbo run dev --filter=farmafinder-parapharmacy-api", "build": "turbo run build", "build:web": "turbo run build --filter=farma-clic-frontend", "test": "turbo run test", From 7274979f62f35d25c31ffe28e4cc2a6c0f472ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 12:34:08 +0200 Subject: [PATCH 02/25] chore: remove Open Food Facts (OFF) integration - Remove off-service.js and off-service.test.js - Remove OFF import from server.js - Simplify /api/products/search to only use CIMA - Remove 'openfoodfacts' from valid product sources - Update frontend-mobile Product interface to remove OFF source - Remove OFF env vars from .env.example - Add PARAPHARMACY_API_URL to .env.example OFF was replaced by the new parapharmacy-api microservice. --- apps/backend/.env.example | 8 +- apps/backend/__tests__/off-service.test.js | 107 ----------- apps/backend/__tests__/server.test.js | 5 - apps/backend/off-service.js | 206 --------------------- apps/backend/server.js | 29 +-- apps/frontend-mobile/services/products.ts | 7 +- 6 files changed, 11 insertions(+), 351 deletions(-) delete mode 100644 apps/backend/__tests__/off-service.test.js delete mode 100644 apps/backend/off-service.js diff --git a/apps/backend/.env.example b/apps/backend/.env.example index b681f3f..7c4aba2 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -20,9 +20,5 @@ VAPID_SUBJECT=mailto:admin@example.com # https://expo.dev/accounts/[username]/settings/access-tokens EXPO_ACCESS_TOKEN= -# Open Food Facts -# Register at: https://world.openfoodfacts.org/ -# User-Agent is REQUIRED per OFF docs (format: AppName/Version (ContactEmail)) -OFF_USER_AGENT=FarmaFinder/1.0 (https://github.com/farmafinder) -OFF_USERNAME= -OFF_PASSWORD= +# Parapharmacy API +PARAPHARMACY_API_URL=http://localhost:3002 diff --git a/apps/backend/__tests__/off-service.test.js b/apps/backend/__tests__/off-service.test.js deleted file mode 100644 index 3bae2e9..0000000 --- a/apps/backend/__tests__/off-service.test.js +++ /dev/null @@ -1,107 +0,0 @@ -import { jest } from '@jest/globals' - -jest.unstable_mockModule('../redis-client.js', () => ({ - default: { - get: jest.fn(async () => null), - setEx: jest.fn(async () => 'OK'), - }, -})) - -jest.unstable_mockModule('axios', () => ({ - default: { - get: jest.fn(async () => ({ data: { products: [] } })), - }, -})) - -const { transformOFFProduct, searchBabyProducts, getBabyProductDetails } = await import('../off-service.js') - -describe('transformOFFProduct', () => { - test('transforms a valid OFF product to unified model', () => { - const offProduct = { - _id: '12345', - product_name: 'Baby Rice', - brands: 'Nestlé', - image_url: 'https://example.com/image.jpg', - nutriscore_grade: 'a', - ingredients_text: 'Rice, Iron, Vitamins', - nova_group: 1, - ecoscore_grade: 'b', - categories_tags: ['en:baby-foods', 'en:baby-rice'], - } - - const result = transformOFFProduct(offProduct) - - expect(result).toEqual({ - id: '12345', - source: 'openfoodfacts', - name: 'Baby Rice', - brand: 'Nestlé', - category: 'baby_food', - image_url: 'https://example.com/image.jpg', - nutriscore: 'a', - ingredients: 'Rice, Iron, Vitamins', - nova_group: 1, - eco_score: 'b', - }) - }) - - test('returns null for null/undefined input', () => { - expect(transformOFFProduct(null)).toBeNull() - expect(transformOFFProduct(undefined)).toBeNull() - }) - - test('returns null when missing required fields', () => { - expect(transformOFFProduct({ _id: '123' })).toBeNull() - expect(transformOFFProduct({ product_name: 'Test' })).toBeNull() - }) - - test('sets default brand to empty string when missing', () => { - const result = transformOFFProduct({ - _id: '999', - product_name: 'Plain Product', - }) - expect(result.brand).toBe('') - expect(result.image_url).toBeNull() - expect(result.nutriscore).toBeNull() - expect(result.ingredients).toBeNull() - expect(result.nova_group).toBeNull() - expect(result.eco_score).toBeNull() - }) - - test('detects baby_milk category from tags', () => { - const result = transformOFFProduct({ - _id: '200', - product_name: 'Infant Formula', - categories_tags: ['en:infant-milk'], - }) - expect(result.category).toBe('baby_milk') - }) - - test('detects baby_cereal category from tags', () => { - const result = transformOFFProduct({ - _id: '300', - product_name: 'Baby Cereal', - categories_tags: ['en:baby-cereals'], - }) - expect(result.category).toBe('baby_cereal') - }) -}) - -describe('searchBabyProducts', () => { - test('returns empty array for short query', async () => { - const result = await searchBabyProducts('a') - expect(result).toEqual([]) - }) - - test('returns empty array for null/empty query', async () => { - expect(await searchBabyProducts(null)).toEqual([]) - expect(await searchBabyProducts('')).toEqual([]) - }) -}) - -describe('getBabyProductDetails', () => { - test('returns null for null barcode', async () => { - const result = await getBabyProductDetails(null) - expect(result).toBeNull() - }) -}) diff --git a/apps/backend/__tests__/server.test.js b/apps/backend/__tests__/server.test.js index 6e38f9e..c252983 100644 --- a/apps/backend/__tests__/server.test.js +++ b/apps/backend/__tests__/server.test.js @@ -6,11 +6,6 @@ jest.unstable_mockModule('../cima-service.js', () => ({ searchOTC: jest.fn(async () => []), })) -jest.unstable_mockModule('../off-service.js', () => ({ - searchBabyProducts: jest.fn(async () => []), - getBabyProductDetails: jest.fn(async () => null), -})) - jest.unstable_mockModule('../farmacias-webhook-import.js', () => ({ runFarmaciaWebhookImport: jest.fn(async () => ({})), DEFAULT_FARMACIAS_WEBHOOK: '', diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js deleted file mode 100644 index 5beb71d..0000000 --- a/apps/backend/off-service.js +++ /dev/null @@ -1,206 +0,0 @@ -import axios from 'axios'; -import redisClient from './redis-client.js'; - -// OFF API v3 (recommended) with fallback to v2 for search -const OFF_API_BASE = 'https://world.openfoodfacts.org'; -const CACHE_TTL = 3600; -const MAX_RETRIES = 2; -const RETRY_DELAY_MS = 1000; - -// User-Agent is REQUIRED per OFF docs: AppName/Version (ContactEmail) -const OFF_USER_AGENT = process.env.OFF_USER_AGENT || 'FarmaFinder/1.0 (https://github.com/farmafinder)'; - -// Open Food Facts authentication (optional, for write operations) -const OFF_USERNAME = process.env.OFF_USERNAME; -const OFF_PASSWORD = process.env.OFF_PASSWORD; - -function getOffHeaders() { - return { - 'User-Agent': OFF_USER_AGENT, - }; -} - -function getOffAuth() { - if (OFF_USERNAME && OFF_PASSWORD) { - return { - username: OFF_USERNAME, - password: OFF_PASSWORD, - }; - } - return null; -} - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -export function transformOFFProduct(offProduct) { - if (!offProduct || !offProduct._id || !offProduct.product_name) { - return null; - } - - const categoriesTags = offProduct.categories_tags || []; - let category = 'baby_food'; - for (const tag of categoriesTags) { - const lower = tag.toLowerCase(); - if (lower.includes('baby-milk') || lower.includes('infant-milk')) { - category = 'baby_milk'; - break; - } - if (lower.includes('baby-cereal') || lower.includes('infant-cereal')) { - category = 'baby_cereal'; - break; - } - } - - return { - id: offProduct._id, - source: 'openfoodfacts', - name: offProduct.product_name, - brand: offProduct.brands || '', - category, - image_url: offProduct.image_url || null, - nutriscore: offProduct.nutriscore_grade || null, - ingredients: offProduct.ingredients_text || null, - nova_group: offProduct.nova_group || null, - eco_score: offProduct.ecoscore_grade || null, - }; -} - -export async function searchBabyProducts(query) { - if (!query || query.trim().length < 2) { - return []; - } - - const searchTerm = query.trim().toLowerCase(); - const cacheKey = `off:baby:${searchTerm}`; - - try { - const cachedData = await redisClient.get(cacheKey); - if (cachedData) { - console.log(`Cache hit for OFF search: ${searchTerm}`); - return JSON.parse(cachedData); - } - - console.log(`Fetching from OFF API: ${searchTerm}`); - const headers = getOffHeaders(); - console.log(`[OFF] User-Agent: ${headers['User-Agent']}`); - console.log(`[OFF] URL: ${OFF_API_BASE}/api/v2/search?brands_tags=${searchTerm}`); - - let lastError; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - // Use v2 structured search with brand filter (more reliable than /cgi/search.pl) - // READ operations don't require auth per OFF docs, only User-Agent - const response = await axios.get(`${OFF_API_BASE}/api/v2/search`, { - params: { - brands_tags: searchTerm, - page_size: 20, - }, - headers, - timeout: 10000, - validateStatus: (status) => status === 200, - }); - - // Check if response is actually JSON (OFF sometimes returns HTML on error) - if (typeof response.data === 'string' || !response.data.products) { - console.warn(`[OFF] Invalid response for "${searchTerm}" (attempt ${attempt + 1}): API may be down`); - if (attempt < MAX_RETRIES) { - await sleep(RETRY_DELAY_MS * (attempt + 1)); - continue; - } - return []; - } - - const products = response.data.products - .map(transformOFFProduct) - .filter(Boolean); - - // Only cache non-empty results to avoid caching API failures - if (products.length > 0) { - try { - await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); - console.log(`Cached ${products.length} OFF products for: ${searchTerm}`); - } catch (cacheErr) { - // cache write failed, still return the data - } - } else { - console.log(`[OFF] No products found for "${searchTerm}", not caching empty result`); - } - return products; - } catch (err) { - lastError = err; - console.warn(`[OFF] Request failed for "${searchTerm}" (attempt ${attempt + 1}): ${err.message}`); - if (attempt < MAX_RETRIES) { - await sleep(RETRY_DELAY_MS * (attempt + 1)); - } - } - } - - console.error(`[OFF] All retries failed for "${searchTerm}": ${lastError?.message}`); - return []; - } catch (error) { - console.error('Error searching baby products from OFF:', error.message); - return []; - } -} - -export async function getBabyProductDetails(barcode) { - if (!barcode) { - return null; - } - - const cacheKey = `off:product:${barcode}`; - - try { - const cachedData = await redisClient.get(cacheKey); - if (cachedData) { - console.log(`Cache hit for OFF product: ${barcode}`); - return JSON.parse(cachedData); - } - - console.log(`Fetching OFF product details: ${barcode}`); - const headers = getOffHeaders(); - // Use v3 API (recommended) for product details - // READ operations don't require auth per OFF docs, only User-Agent - const response = await axios.get(`${OFF_API_BASE}/api/v3/product/${barcode}.json`, { - headers, - timeout: 10000, - validateStatus: (status) => status === 200, - }); - - if (response.data && response.data.product) { - const product = transformOFFProduct(response.data.product); - - if (product) { - try { - await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); - console.log(`Cached OFF product: ${barcode}`); - } catch (cacheErr) { - // cache write failed, still return the data - } - return product; - } - } - - return null; - } catch (error) { - console.error(`Error fetching OFF product ${barcode}:`, error.message); - return null; - } -} - -export async function clearCache(pattern = 'off:*') { - try { - const keys = await redisClient.keys(pattern); - if (keys.length > 0) { - await redisClient.del(keys); - console.log(`Cleared ${keys.length} OFF cache entries`); - return keys.length; - } - return 0; - } catch (error) { - console.error('Error clearing OFF cache:', error); - return 0; - } -} diff --git a/apps/backend/server.js b/apps/backend/server.js index 1968843..f2e5c7c 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -23,7 +23,6 @@ import pino from 'pino'; import pinoHttp from 'pino-http'; import multer from 'multer'; import { searchMedicines, getMedicineDetails, searchOTC } from './cima-service.js'; -import { searchBabyProducts, getBabyProductDetails } from './off-service.js'; import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js'; import { fetchPharmaciesExternal } from '../API/index.js'; @@ -680,7 +679,7 @@ app.get('/api/medicines/:medicineId', async (req, res) => { } }); -// ========== UNIFIED PRODUCT SEARCH ========== +// ========== OTC PRODUCT SEARCH (CIMA only) ========== app.get('/api/products/search', searchLimiter, async (req, res) => { try { @@ -689,18 +688,11 @@ app.get('/api/products/search', searchLimiter, async (req, res) => { return res.json({ results: [], total: 0 }); } const searchTerm = q.trim(); - const [cimaResults, offResults] = await Promise.allSettled([ - searchOTC(searchTerm), - searchBabyProducts(searchTerm) - ]); - const cimaProducts = cimaResults.status === 'fulfilled' ? cimaResults.value : []; - const offProducts = offResults.status === 'fulfilled' ? offResults.value : []; - const allProducts = [...cimaProducts, ...offProducts] - .sort((a, b) => a.name.localeCompare(b.name)); + const cimaProducts = await searchOTC(searchTerm); res.json({ - results: allProducts, - total: allProducts.length, - sources: { cima: cimaProducts.length, openfoodfacts: offProducts.length } + results: cimaProducts, + total: cimaProducts.length, + sources: { cima: cimaProducts.length } }); } catch (err) { console.error('[Products] Search error:', err); @@ -716,11 +708,6 @@ app.get('/api/products/:source/:id', async (req, res) => { if (!product) return res.status(404).json({ error: 'Product not found' }); return res.json({ ...product, source: 'cima', category: 'otc' }); } - if (source === 'openfoodfacts') { - const product = await getBabyProductDetails(id); - if (!product) return res.status(404).json({ error: 'Product not found' }); - return res.json(product); - } res.status(400).json({ error: 'Invalid source' }); } catch (err) { console.error('[Products] Detail error:', err); @@ -733,7 +720,7 @@ app.get('/api/products/:source/:productId/pharmacies', async (req, res) => { try { const { source, productId } = req.params; - if (source !== 'cima' && source !== 'openfoodfacts') { + if (source !== 'cima') { return res.status(400).json({ error: 'Invalid source' }); } @@ -2137,8 +2124,8 @@ app.post('/api/admin/pharmacy-products', requireAdmin, async (req, res) => { return res.status(400).json({ error: 'pharmacy_id, product_source, and product_off_id are required' }); } - if (product_source !== 'cima' && product_source !== 'openfoodfacts') { - return res.status(400).json({ error: 'product_source must be "cima" or "openfoodfacts"' }); + if (product_source !== 'cima') { + return res.status(400).json({ error: 'product_source must be "cima"' }); } const existing = await userDbGet( diff --git a/apps/frontend-mobile/services/products.ts b/apps/frontend-mobile/services/products.ts index 078a386..43f85ac 100644 --- a/apps/frontend-mobile/services/products.ts +++ b/apps/frontend-mobile/services/products.ts @@ -3,7 +3,7 @@ import api from './api'; export interface Product { id: string; _id?: string; - source: 'cima' | 'openfoodfacts' | 'promofarma' | 'pharmarket' | 'docmorris' | '1001farma' | 'primor' | 'mifarma'; + source: 'cima' | 'promofarma' | 'pharmarket' | 'docmorris' | '1001farma' | 'primor' | 'mifarma'; name: string; brand: string; category: string; @@ -22,10 +22,6 @@ export interface Product { commercialized?: boolean; photos?: { tipo: string; url: string }[]; docs?: { tipo: number; url: string }[]; - nutriscore?: string; - ingredients?: string; - nova_group?: number; - eco_score?: string; } export interface ProductSearchResponse { @@ -35,7 +31,6 @@ export interface ProductSearchResponse { pages?: number; sources?: { cima: number; - openfoodfacts: number; }; } From c509253764b9e2b76eab51d4a71db667318104b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 12:37:54 +0200 Subject: [PATCH 03/25] fix: correct Dockerfile build context for parapharmacy-api Changed build context from '.' to './apps/parapharmacy-api' so the Dockerfile can find the src/ directory. --- docker-compose.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d9ce987..1dd5cbd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,7 @@ services: OTEL_TRACES_EXPORTER: otlp OTEL_METRICS_EXPORTER: otlp OTEL_LOGS_EXPORTER: otlp + PARAPHARMACY_API_URL: http://parapharmacy-api:3002 OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmafinder volumes: - backend_data:/app/data @@ -88,8 +89,8 @@ services: parapharmacy-api: image: git.hacecalor.net/ichitux/farmafinder-parapharmacy-api:latest build: - context: . - dockerfile: apps/parapharmacy-api/Dockerfile + context: ./apps/parapharmacy-api + dockerfile: Dockerfile restart: unless-stopped ports: - "3002:3002" @@ -133,11 +134,6 @@ services: depends_on: - postgres - # --- Parapharmacy Backend connection --- - backend: - environment: - PARAPHARMACY_API_URL: http://parapharmacy-api:3002 - volumes: backend_data: postgres_data: From 0c2f1cf9288cdc83c041df175c99242120c36c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 12:40:22 +0200 Subject: [PATCH 04/25] fix: use npm install instead of npm ci in Dockerfile npm ci requires package-lock.json which is at root level in monorepo. npm install works without it. --- apps/parapharmacy-api/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/parapharmacy-api/Dockerfile b/apps/parapharmacy-api/Dockerfile index 2a630c0..a29c043 100644 --- a/apps/parapharmacy-api/Dockerfile +++ b/apps/parapharmacy-api/Dockerfile @@ -5,8 +5,8 @@ WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies -RUN npm ci --only=production +# Install dependencies (production only) +RUN npm install --omit=dev # Copy source code COPY src/ ./src/ From 1c70f0a914cf92c1ee5c66db25722aa707cf8d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 12:46:20 +0200 Subject: [PATCH 05/25] feat: add N8N workflows and auto-setup configuration - Add parapharmacy-scraper workflow (scheduled every 3 days) - Add parapharmacy-manual-scraper workflow (webhook triggered) - Configure N8N to auto-create owner account (skips /setup) - Add N8N environment variables to docker-compose.yml - Add README with setup and usage instructions --- docker-compose.yml | 10 + n8n/README.md | 89 +++++++++ .../parapharmacy-manual-scraper.json | 181 +++++++++++++++++ n8n/workflows/parapharmacy-scraper.json | 182 ++++++++++++++++++ 4 files changed, 462 insertions(+) create mode 100644 n8n/README.md create mode 100644 n8n/workflows/parapharmacy-manual-scraper.json create mode 100644 n8n/workflows/parapharmacy-scraper.json diff --git a/docker-compose.yml b/docker-compose.yml index 1dd5cbd..191b7a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -118,19 +118,29 @@ services: ports: - "5678:5678" environment: + # Owner account (skips /setup) + N8N_USER_MANAGEMENT_DISABLED: "false" + N8N_OWNER_EMAIL: ${N8N_EMAIL:-admin@farmafinder.com} + N8N_OWNER_PASSWORD: ${N8N_PASSWORD:-change-me} + # Auth N8N_BASIC_AUTH_ACTIVE: "true" N8N_BASIC_AUTH_USER: ${N8N_USER:-admin} N8N_BASIC_AUTH_PASSWORD: ${N8N_PASSWORD:-change-me} + # Database DB_TYPE: postgresdb DB_POSTGRESDB_HOST: postgres DB_POSTGRESDB_DATABASE: farmafinder DB_POSTGRESDB_USER: farmafinder DB_POSTGRESDB_PASSWORD: ${PG_PASSWORD:-change-me-in-production} + # Network N8N_HOST: localhost N8N_PORT: 5678 N8N_PROTOCOL: http + # Webhook URL for external calls + WEBHOOK_URL: http://localhost:5678/ volumes: - n8n_data:/home/node/.n8n + - ./n8n/workflows:/home/node/workflows depends_on: - postgres diff --git a/n8n/README.md b/n8n/README.md new file mode 100644 index 0000000..743de0d --- /dev/null +++ b/n8n/README.md @@ -0,0 +1,89 @@ +# N8N Workflows for FarmaFinder Parapharmacy + +## Configuración Inicial + +Al iniciar N8N por primera vez, se creará automáticamente una cuenta de administrador: + +- **Email**: admin@farmafinder.com (configurable en `.env`) +- **Password**: change-me (configurable en `.env`) + +### Variables de Entorno + +```bash +# En el archivo .env de la raíz del proyecto +N8N_USER=admin +N8N_PASSWORD=change-me +N8N_EMAIL=admin@farmafinder.com +``` + +## Workflows Incluidos + +### 1. Parapharmacy Scraper (Automático) + +- **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) + +### 2. Parapharmacy Manual Scraper (Webhook) + +- **Trigger**: POST a `/webhook/scrape-parapharmacy` +- **Función**: Scraping bajo demanda +- **Estado**: Activo por defecto + +## Uso del Webhook Manual + +```bash +# Ejecutar scraping con queries por defecto +curl -X POST http://localhost:5678/webhook/scrape-parapharmacy + +# Ejecutar scraping con queries específicas +curl -X POST http://localhost:5678/webhook/scrape-parapharmacy \ + -H "Content-Type: application/json" \ + -d '{"queries": ["crema hidratante", "protector solar"]}' +``` + +## Endpoints de la API de Parafarmacia + +Los workflows envían los productos scrapeados a: + +``` +POST http://parapharmacy-api:3002/api/products/bulk +``` + +## Monitoreo + +- **N8N Dashboard**: http://localhost:5678 +- **Historial de ejecuciones**: http://localhost:5678/executions +- **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 +``` + +### 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` + +### 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 diff --git a/n8n/workflows/parapharmacy-manual-scraper.json b/n8n/workflows/parapharmacy-manual-scraper.json new file mode 100644 index 0000000..545a8ca --- /dev/null +++ b/n8n/workflows/parapharmacy-manual-scraper.json @@ -0,0 +1,181 @@ +{ + "name": "Parapharmacy Manual Scraper (Webhook)", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "scrape-parapharmacy", + "options": {} + }, + "id": "webhook-trigger", + "name": "Webhook Trigger", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [220, 300], + "webhookId": "scrape-parapharmacy" + }, + { + "parameters": { + "values": { + "string": [ + { + "name": "queries", + "value": "={{ $json.body?.queries || 'crema hidratante cara,protector solar,leche corporal' }}" + } + ] + }, + "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": { + "method": "GET", + "url": "https://www.promofarma.com/es/search?q={{ encodeURIComponent($json.queries) }}", + "options": { + "timeout": 30000 + } + }, + "id": "scrape-promofarma", + "name": "Scrape Promofarma", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [880, 300] + }, + { + "parameters": { + "jsCode": "// Extract products from Promofarma HTML\nconst html = $input.first().json.data;\nconst products = [];\n\nconst productRegex = /
]*>([\\s\\S]*?)<\\/div>/gi;\nconst nameRegex = /]*>([^<]+)<\\/h3>/i;\nconst priceRegex = /class=\"price[^\"]*\"[^>]*>([^<]+)<\\/span>/i;\nconst linkRegex = /]*href=\"([^\"]+)\"[^>]*>/i;\n\nlet match;\nwhile ((match = productRegex.exec(html)) !== null) {\n const card = match[1];\n const name = nameRegex.exec(card)?.[1]?.trim();\n const price = priceRegex.exec(card)?.[1]?.trim();\n const link = linkRegex.exec(card)?.[1];\n \n if (name) {\n products.push({\n name,\n price: parseFloat(price?.replace(/[^\\d.,]/g, '').replace(',', '.')) || 0,\n source_url: link?.startsWith('http') ? link : `https://www.promofarma.com${link}`,\n source: 'promofarma',\n source_product_id: link?.match(/\\/p\\/([^/]+)/)?.[1] || Date.now().toString()\n });\n }\n}\n\nreturn products.map(p => ({ json: p }));" + }, + "id": "extract-promofarma", + "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(item => item.json) }) }}", + "options": {} + }, + "id": "send-to-api", + "name": "Send to Parapharmacy API", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1320, 300] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { success: true, products: $input.all().length } }}" + }, + "id": "respond", + "name": "Respond", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [1540, 300] + } + ], + "connections": { + "Webhook Trigger": { + "main": [ + [ + { + "node": "Set Queries", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set Queries": { + "main": [ + [ + { + "node": "Split Queries", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split Queries": { + "main": [ + [ + { + "node": "Scrape Promofarma", + "type": "main", + "index": 0 + } + ] + ] + }, + "Scrape Promofarma": { + "main": [ + [ + { + "node": "Extract Products", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Products": { + "main": [ + [ + { + "node": "Send to Parapharmacy API", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send to Parapharmacy API": { + "main": [ + [ + { + "node": "Respond", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1" + }, + "versionId": "1", + "tags": [ + { + "name": "parapharmacy" + }, + { + "name": "scraper" + }, + { + "name": "webhook" + } + ] +} diff --git a/n8n/workflows/parapharmacy-scraper.json b/n8n/workflows/parapharmacy-scraper.json new file mode 100644 index 0000000..6c5903a --- /dev/null +++ b/n8n/workflows/parapharmacy-scraper.json @@ -0,0 +1,182 @@ +{ + "name": "Parapharmacy Scraper", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "0 2 */3 * *" + } + ] + } + }, + "id": "schedule-trigger", + "name": "Schedule (Every 3 days at 2am)", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [220, 300] + }, + { + "parameters": { + "values": { + "string": [ + { + "name": "queries", + "value": "crema hidratante cara,protector solar,leche corporal,champú bebé,aceite bebé,crema pañal,vitaminas bebé,formula lactea" + } + ] + }, + "options": {} + }, + "id": "set-queries", + "name": "Set Search 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": { + "method": "GET", + "url": "https://www.promofarma.com/es/search?q={{ encodeURIComponent($json.queries) }}", + "options": { + "timeout": 30000 + } + }, + "id": "scrape-promofarma", + "name": "Scrape Promofarma", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [880, 200] + }, + { + "parameters": { + "jsCode": "// Extract products from Promofarma HTML\nconst html = $input.first().json.data;\nconst products = [];\n\n// Simple regex extraction (adjust selectors based on actual HTML)\nconst productRegex = /
]*>([\\s\\S]*?)<\\/div>/gi;\nconst nameRegex = /]*>([^<]+)<\\/h3>/i;\nconst priceRegex = /class=\"price[^\"]*\"[^>]*>([^<]+)<\\/span>/i;\nconst linkRegex = /]*href=\"([^\"]+)\"[^>]*>/i;\n\nlet match;\nwhile ((match = productRegex.exec(html)) !== null) {\n const card = match[1];\n const name = nameRegex.exec(card)?.[1]?.trim();\n const price = priceRegex.exec(card)?.[1]?.trim();\n const link = linkRegex.exec(card)?.[1];\n \n if (name) {\n products.push({\n name,\n price: parseFloat(price?.replace(/[^\\d.,]/g, '').replace(',', '.')) || 0,\n source_url: link?.startsWith('http') ? link : `https://www.promofarma.com${link}`,\n source: 'promofarma',\n source_product_id: link?.match(/\\/p\\/([^/]+)/)?.[1] || Date.now().toString()\n });\n }\n}\n\nreturn products.map(p => ({ json: p }));" + }, + "id": "extract-promofarma", + "name": "Extract Products", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1100, 200] + }, + { + "parameters": { + "method": "POST", + "url": "http://parapharmacy-api:3002/api/products/bulk", + "sendBody": true, + "bodyParameters": { + "parameters": [] + }, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ products: $input.all().map(item => item.json) }) }}", + "options": {} + }, + "id": "send-to-api", + "name": "Send to Parapharmacy API", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1320, 200] + }, + { + "parameters": {}, + "id": "no-operation", + "name": "No Op", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1540, 200] + } + ], + "connections": { + "Schedule (Every 3 days at 2am)": { + "main": [ + [ + { + "node": "Set Search Queries", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set Search Queries": { + "main": [ + [ + { + "node": "Split Queries", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split Queries": { + "main": [ + [ + { + "node": "Scrape Promofarma", + "type": "main", + "index": 0 + } + ] + ] + }, + "Scrape Promofarma": { + "main": [ + [ + { + "node": "Extract Products", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Products": { + "main": [ + [ + { + "node": "Send to Parapharmacy API", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send to Parapharmacy API": { + "main": [ + [ + { + "node": "No Op", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "1", + "tags": [ + { + "name": "parapharmacy" + }, + { + "name": "scraper" + } + ] +} From 52ddb9c21c45f373f2af2edf2ff35399c1561f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 12:47:04 +0200 Subject: [PATCH 06/25] chore: add .env.example with all environment variables --- .env.example | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6559bfb --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# FarmaFinder Environment Variables + +# PostgreSQL +PG_PASSWORD=change-me-in-production + +# Backend +SESSION_SECRET=change-me-in-production +CORS_ORIGIN=http://localhost:4000 +FARMACIAS_WEBHOOK_URL= + +# Redis +REDIS_PASSWORD= + +# N8N Workflow Automation +N8N_USER=admin +N8N_PASSWORD=change-me +N8N_EMAIL=admin@farmafinder.com + +# Parapharmacy API +PARAPHARMACY_API_URL=http://parapharmacy-api:3002 +MONGODB_URI=mongodb://mongodb:27017/parapharmacy + +# Expo Push Notifications (mobile) +EXPO_ACCESS_TOKEN= + +# OpenTelemetry +VITE_FARO_ENDPOINT=http://localhost:4318 +VITE_FARO_APP_NAME=farmafinder-frontend +VITE_FARO_ENV=production +VITE_FARO_APP_VERSION=1.0.0 From cb564fb1709b0c7ba0fc811622ab1e5ff66447a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 12:51:48 +0200 Subject: [PATCH 07/25] docs: add comprehensive parapharmacy and N8N documentation - Create docs/parapharmacy.md with full API, N8N, and setup documentation - Update README.md with parapharmacy system overview and links - Add project structure for parapharmacy-api and n8n - Update Docker setup section with all services - Add API endpoints for parapharmacy - Add N8N workflow automation section --- README.md | 141 +++++++++++++++--- docs/parapharmacy.md | 347 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 469 insertions(+), 19 deletions(-) create mode 100644 docs/parapharmacy.md diff --git a/README.md b/README.md index 3dcf358..d9069f0 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,11 @@ A web application to search for medicines from the official Spanish CIMA databas | App | Stack | |-----|-------| -| Backend | Node.js + Express, SQLite, Redis | +| Backend | Node.js + Express, SQLite/PostgreSQL, Redis | +| Parapharmacy API | Node.js + Express, MongoDB | | Frontend (Web) | React + Vite, Capacitor | | Frontend (Mobile) | Expo SDK 57 + React Native, Zustand, Axios + TanStack Query | +| Workflow Automation | N8N | | Build system | Turborepo | | Package manager | npm workspaces | @@ -50,10 +52,11 @@ This is a **Turborepo monorepo**. All applications live under `apps/`: FarmaFinder/ ├── package.json # Root: workspaces + turbo scripts ├── turbo.json # Turborepo task configuration -├── docker-compose.yml # Full stack: backend + frontend + Redis + Postgres +├── docker-compose.yml # Full stack: backend + frontend + Redis + Postgres + MongoDB + N8N +├── .env.example # Environment variables template │ ├── apps/ -│ ├── backend/ # Node.js + Express API +│ ├── backend/ # Node.js + Express API (medicines) │ │ ├── Dockerfile │ │ ├── server.js # Express server and API routes │ │ ├── cima-service.js # CIMA API integration with Redis cache @@ -62,6 +65,18 @@ FarmaFinder/ │ │ ├── create-admin.js # Admin user creation script │ │ └── package.json │ │ +│ ├── parapharmacy-api/ # Parapharmacy products API +│ │ ├── Dockerfile +│ │ ├── src/ +│ │ │ ├── server.js # Express + Swagger +│ │ │ ├── config.js # Configuration +│ │ │ ├── models/ +│ │ │ │ └── Product.js +│ │ │ └── routes/ +│ │ │ └── products.js +│ │ ├── README.md +│ │ └── package.json +│ │ │ ├── frontend/ # React + Vite (Desktop/PWA) │ │ ├── Dockerfile │ │ ├── nginx.conf # Nginx config for Docker @@ -79,7 +94,7 @@ FarmaFinder/ │ │ ├── store/ │ │ └── package.json │ │ -│ ├── scraper/ # Puppeteer scraper (standalone) +│ ├── scraper/ # Puppeteer scraper (legacy) │ │ └── package.json │ │ │ └── pip-platform/ # Python FastAPI platform (separate docker-compose) @@ -87,11 +102,29 @@ FarmaFinder/ │ ├── docker-compose.yml │ └── pyproject.toml │ +├── n8n/ # N8N workflow automation +│ ├── workflows/ # Workflow JSON files +│ │ ├── parapharmacy-scraper.json +│ │ └── parapharmacy-manual-scraper.json +│ └── README.md +│ ├── API/ # Shared API source files ├── scripts/ # Build/utility scripts └── docs/ # Documentation + └── parapharmacy.md # Parapharmacy system documentation ``` +## Features + +### Parapharmacy Search +- Scraping de múltiples tiendas españolas (Promofarma, Pharmarket, DocMorris, etc.) +- API REST dedicada con MongoDB +- Scraping automático cada 3 días via N8N +- Búsqueda full-text por nombre, marca y categoría +- [Documentación completa](docs/parapharmacy.md) + +--- + ## Quick Start ### Install dependencies @@ -141,34 +174,53 @@ npm test --workspace=farma-clic-frontend ## Docker Setup -Runs the full stack (backend, frontend, Redis, Postgres) with a single command. +Runs the full stack with a single command. ```bash -# Copy and configure environment (optional - defaults work for local dev) -cp apps/backend/.env.example apps/backend/.env +# Copy and configure environment +cp .env.example .env +# Edit .env with your settings (especially passwords) docker compose up --build ``` -App available at `http://localhost:4000` (frontend) and `http://localhost:3001` (backend API). +### Services + +| Service | URL | Description | +|---------|-----|-------------| +| Frontend | http://localhost:4000 | React web app | +| Backend API | http://localhost:3001 | Medicines API | +| Parapharmacy API | http://localhost:3002 | Parapharmacy products API | +| Swagger Docs | http://localhost:3002/api/docs | API documentation | +| N8N | http://localhost:5678 | Workflow automation | + +### First Run -**First run - create an admin user:** ```bash +# Create admin user for FarmaFinder docker compose exec backend node create-admin.js -# Default: admin / admin123 - change after first login -``` +# Default: admin / admin123 -**Seed sample pharmacies:** -```bash +# Seed sample pharmacies docker compose exec backend node seed.js ``` -**Stop:** +### N8N Setup + +N8N auto-creates an admin account on first start: +- **Email**: admin@farmafinder.com +- **Password**: change-me (configurable in `.env`) + +See [Parapharmacy Documentation](docs/parapharmacy.md) for details. + +### Stop + ```bash docker compose down ``` -Database is persisted in named Docker volumes (`backend_data`, `postgres_data`). To wipe: +### Reset Data + ```bash docker compose down -v ``` @@ -225,18 +277,26 @@ npm run dev ## API Endpoints -### Public -- `GET /api/medicines/search?q=` - Search medicines (CIMA API, cached in Redis) +### Medicines (Backend - Port 3001) + +**Public:** +- `GET /api/medicines/search?q=` - Search medicines (CIMA API) - `GET /api/medicines/:nregistro` - Medicine details - `GET /api/medicines/:nregistro/pharmacies` - Pharmacies selling a medicine - `GET /api/pharmacies` - All pharmacies -### Auth +**Parapharmacy Proxy:** +- `GET /api/products/parapharmacy/search?q=` - Search parapharmacy products +- `GET /api/products/parapharmacy/:id` - Parapharmacy product details +- `GET /api/products/parapharmacy/categories` - List categories +- `GET /api/products/parapharmacy/brands` - List brands + +**Auth:** - `POST /api/auth/login` - Login - `POST /api/auth/logout` - Logout - `GET /api/auth/check` - Check auth status -### Admin (requires authentication) +**Admin:** - `POST /api/admin/pharmacies` - Add pharmacy - `PUT /api/admin/pharmacies/:id` - Update pharmacy - `DELETE /api/admin/pharmacies/:id` - Delete pharmacy @@ -246,6 +306,23 @@ npm run dev - `PUT /api/admin/pharmacy-medicines/:id` - Update price/stock - `DELETE /api/admin/pharmacy-medicines/:id` - Remove link +### Parapharmacy API (Port 3002) + +- `GET /api/products/search?q=` - Search products (full-text) +- `GET /api/products/:id` - Product details +- `GET /api/products` - List products +- `POST /api/products` - Create product +- `POST /api/products/bulk` - Bulk upsert (for scrapers) +- `PUT /api/products/:id` - Update product +- `DELETE /api/products/:id` - Delete product +- `GET /api/products/categories` - List categories +- `GET /api/products/brands` - List brands +- `GET /api/sources` - Configured sources +- `GET /api/health` - Health check +- `GET /api/docs` - Swagger documentation + +See [Parapharmacy Documentation](docs/parapharmacy.md) for details. + ## Database Schema ### SQLite Tables @@ -320,6 +397,32 @@ const ENV = { }; ``` +## N8N Workflow Automation + +N8N handles automated scraping of parapharmacy products. + +### Access +- **URL**: http://localhost:5678 +- **Email**: admin@farmafinder.com +- **Password**: change-me (change in `.env`) + +### Workflows + +| Workflow | Trigger | Description | +|----------|---------|-------------| +| Parapharmacy Scraper | Every 3 days at 2am | Automatic scraping (inactive by default) | +| Parapharmacy Manual Scraper | POST `/webhook/scrape-parapharmacy` | On-demand scraping | + +### Manual Scraping + +```bash +curl -X POST http://localhost:5678/webhook/scrape-parapharmacy +``` + +See [N8N Documentation](n8n/README.md) for details. + +--- + ## Troubleshooting ### Redis Connection Issues diff --git a/docs/parapharmacy.md b/docs/parapharmacy.md new file mode 100644 index 0000000..1575791 --- /dev/null +++ b/docs/parapharmacy.md @@ -0,0 +1,347 @@ +# FarmaFinder Parapharmacy System + +Sistema completo de búsqueda de productos de parafarmacia mediante scraping de múltiples tiendas españolas. + +## Arquitectura + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ N8N Workflows │────▶│ Parapharmacy │────▶│ MongoDB │ +│ (Scraper) │ │ API (Express) │ │ │ +└─────────────────┘ └────────┬─────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ FarmaFinder │ + │ Backend │ + └─────────────────┘ +``` + +## Componentes + +| Componente | Puerto | Descripción | +|------------|--------|-------------| +| Parapharmacy API | 3002 | API REST para productos de parafarmacia | +| MongoDB | 27017 | Base de datos de productos | +| N8N | 5678 | Automatización de workflows y scraping | + +--- + +## Parapharmacy API + +### Endpoints + +| Método | Ruta | Descripción | +|--------|------|-------------| +| GET | `/api/products/search?q=term` | Buscar productos (full-text) | +| GET | `/api/products/:id` | Detalle de producto | +| GET | `/api/products` | Listar productos (con filtros) | +| POST | `/api/products` | Crear producto | +| POST | `/api/products/bulk` | Crear/actualizar múltiples (para scraper) | +| PUT | `/api/products/:id` | Actualizar producto | +| DELETE | `/api/products/:id` | Eliminar producto | +| GET | `/api/products/categories` | Listar categorías | +| GET | `/api/products/brands` | Listar marcas | +| GET | `/api/sources` | Fuentes configuradas | +| GET | `/api/health` | Health check | +| GET | `/api/docs` | Swagger UI | + +### Ejemplo de Búsqueda + +```bash +# Buscar "capricare" +curl "http://localhost:3002/api/products/search?q=capricare" + +# Buscar con filtros +curl "http://localhost:3002/api/products/search?q=crema&category=dermocosmetica&brand=bioderma" +``` + +### Respuesta + +```json +{ + "results": [ + { + "_id": "...", + "name": "Capricare 1 Leche en polvo", + "brand": "Capricare", + "category": "Fórmulas lácteas", + "price": 12.99, + "original_price": 14.99, + "image_url": "https://...", + "source": "promofarma", + "source_url": "https://promofarma.com/..." + } + ], + "total": 5, + "page": 1, + "pages": 1 +} +``` + +### Schema MongoDB + +```javascript +{ + name: String, // Nombre del producto + brand: String, // Marca + category: String, // Categoría principal + subcategory: String, // Subcategoría + description: String, // Descripción + image_url: String, // URL de la imagen + source_url: String, // URL en la tienda original + price: Number, // Precio actual + original_price: Number, // Precio anterior (si hay descuento) + currency: String, // EUR por defecto + source: String, // 'promofarma', 'pharmarket', etc. + source_product_id: String, + available: Boolean, // Disponible actualmente + rating: Number, // Valoración (0-5) + review_count: Number, + scraped_at: Date, // Cuándo se scrappeó + created_at: Date, + updated_at: Date +} +``` + +--- + +## N8N Configuration + +### Cuenta de Administrador + +Al iniciar N8N por primera vez, se crea automáticamente una cuenta de administrador: + +| Campo | Valor | +|-------|-------| +| **Email** | admin@farmafinder.com | +| **Password** | change-me | + +> **IMPORTANTE**: Cambia la contraseña después del primer login. + +### Variables de Entorno (`.env`) + +```bash +# N8N Configuration +N8N_USER=admin +N8N_PASSWORD=change-me +N8N_EMAIL=admin@farmafinder.com + +# Parapharmacy API +PARAPHARMACY_API_URL=http://parapharmacy-api:3002 +MONGODB_URI=mongodb://mongodb:27017/parapharmacy +``` + +### Acceso a N8N + +- **URL**: http://localhost:5678 +- **Email**: admin@farmafinder.com +- **Password**: change-me + +--- + +## Workflows de Scraping + +### 1. Parapharmacy Scraper (Automático) + +- **Trigger**: Cada 3 días a las 2:00 AM +- **Estado**: Inactivo por defecto +- **Función**: Scraping automático de Promofarma + +**Para activar:** +1. Ir a http://localhost:5678/workflows +2. Abrir "Parapharmacy Scraper" +3. Hacer clic en "Active" toggle + +### 2. Parapharmacy Manual Scraper (Webhook) + +- **Trigger**: POST a `/webhook/scrape-parapharmacy` +- **Estado**: Activo por defecto +- **Función**: Scraping bajo demanda + +**Uso:** + +```bash +# Scraping con queries por defecto +curl -X POST http://localhost:5678/webhook/scrape-parapharmacy + +# Scraping con queries específicas +curl -X POST http://localhost:5678/webhook/scrape-parapharmacy \ + -H "Content-Type: application/json" \ + -d '{"queries": ["crema hidratante", "protector solar"]}' +``` + +--- + +## Fuentes de Scraping + +| 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 | + +--- + +## Integración con FarmaFinder Backend + +El backend principal proxies las peticiones a la API de parafarmacia: + +### Endpoints Proxy + +| Método | Ruta | Descripción | +|--------|------|-------------| +| GET | `/api/products/parapharmacy/search` | Buscar productos | +| GET | `/api/products/parapharmacy/:id` | Detalle de producto | +| GET | `/api/products/parapharmacy/categories` | Categorías | +| GET | `/api/products/parapharmacy/brands` | Marcas | + +### Ejemplo desde Frontend + +```javascript +// Buscar productos de parafarmacia +const response = await fetch('/api/products/parapharmacy/search?q=capricare'); +const data = await response.json(); +// data.results = [{ name: "Capricare...", price: 12.99, ... }] +``` + +--- + +## Docker Setup + +### Servicios + +```yaml +services: + parapharmacy-api: # Puerto 3002 + mongodb: # Puerto 27017 + n8n: # Puerto 5678 +``` + +### Iniciar + +```bash +# Todos los servicios +docker-compose up -d + +# Solo parafarmacia +docker-compose up -d parapharmacy-api mongodb n8n + +# Ver logs +docker-compose logs -f parapharmacy-api +docker-compose logs -f n8n +``` + +### Detener + +```bash +docker-compose down +``` + +### Limpiar datos + +```bash +# Eliminar volumes (borra datos) +docker-compose down -v +``` + +--- + +## Troubleshooting + +### N8N muestra página /setup + +Verifica que las variables de entorno estén configuradas: + +```bash +N8N_OWNER_EMAIL=admin@farmafinder.com +N8N_OWNER_PASSWORD=change-me +``` + +### Webhook no funciona + +1. Verifica que el workflow esté activo en N8N +2. Revisa el historial de ejecuciones: http://localhost:5678/executions +3. Revisa logs: `docker logs n8n` + +### Productos no se guardan + +1. Verifica que parapharmacy-api esté ejecutándose +2. Revisa logs: `docker logs parapharmacy-api` +3. Verifica que MongoDB esté conectado +4. Prueba el health check: `curl http://localhost:3002/api/health` + +### MongoDB no conecta + +```bash +# Verificar que MongoDB está corriendo +docker-compose ps mongodb + +# Ver logs +docker-compose logs mongodb + +# Reiniciar +docker-compose restart mongodb +``` + +### API devuelve error 500 + +```bash +# Ver logs de la API +docker-compose logs parapharmacy-api + +# Verificar conexión a MongoDB +docker-compose exec mongodb mongosh --eval "db.adminCommand('ping')" +``` + +--- + +## Desarrollo + +### Estructura de Archivos + +``` +apps/parapharmacy-api/ +├── src/ +│ ├── server.js # Express + Swagger +│ ├── config.js # Configuración +│ ├── models/ +│ │ └── Product.js # Schema MongoDB +│ └── routes/ +│ └── products.js # Endpoints +├── Dockerfile +├── package.json +└── README.md + +n8n/ +├── workflows/ +│ ├── parapharmacy-scraper.json +│ └── parapharmacy-manual-scraper.json +└── README.md +``` + +### Ejecutar en Desarrollo + +```bash +# API +cd apps/parapharmacy-api +npm run dev + +# MongoDB (necesario) +docker run -d -p 27017:27017 mongo:7 +``` + +### Tests + +```bash +cd apps/parapharmacy-api +npm test +``` + +### Swagger Docs + +Acceso a documentación interactiva: +http://localhost:3002/api/docs From 30f97fe87d8d6cb7b019a04173c3c8f2b58a8597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 13:00:20 +0200 Subject: [PATCH 08/25] feat: complete N8N scrapers for 6 sources and seed script - Create comprehensive N8N workflow for all 6 parapharmacy sources - Create webhook-triggered manual scraping workflow - Add seed script with 20 sample products - Update n8n/README.md with complete documentation - Update docs/parapharmacy.md with seeding instructions - Add seed script to package.json Sources configured: - Promofarma - Pharmarket - DocMorris - 1001Farma - Primor - MiFarma --- apps/parapharmacy-api/package.json | 1 + apps/parapharmacy-api/scripts/seed.js | 111 +++++ docs/parapharmacy.md | 68 ++- n8n/README.md | 157 ++++-- n8n/workflows/parapharmacy-all-sources.json | 134 +++++ .../parapharmacy-webhook-scraper.json | 99 ++++ package-lock.json | 471 +++++++++++++++++- 7 files changed, 973 insertions(+), 68 deletions(-) create mode 100644 apps/parapharmacy-api/scripts/seed.js create mode 100644 n8n/workflows/parapharmacy-all-sources.json create mode 100644 n8n/workflows/parapharmacy-webhook-scraper.json 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" From e2dd3474a4c1914ecbb04c761b1bdab9ffdfc5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 13:06:17 +0200 Subject: [PATCH 09/25] fix: add 'seed' and 'manual' to Product source enum Allows seed script and manual product creation to work. --- apps/parapharmacy-api/src/models/Product.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/parapharmacy-api/src/models/Product.js b/apps/parapharmacy-api/src/models/Product.js index 2386c9c..7007cb7 100644 --- a/apps/parapharmacy-api/src/models/Product.js +++ b/apps/parapharmacy-api/src/models/Product.js @@ -46,7 +46,7 @@ const productSchema = new mongoose.Schema({ source: { type: String, required: true, - enum: ['promofarma', 'pharmarket', 'docmorris', '1001farma', 'primor', 'mifarma'], + enum: ['promofarma', 'pharmarket', 'docmorris', '1001farma', 'primor', 'mifarma', 'seed', 'manual'], index: true, }, source_product_id: { From 2ad42102213156e95dbfbd1730a128018e4e0c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 13:14:56 +0200 Subject: [PATCH 10/25] feat: add scrape script and fix N8N webhook workflow - Add direct scrape.js script for testing - Fix N8N webhook workflow (lastNode response mode) - Note: Real scraping requires Puppeteer for anti-bot sites --- apps/parapharmacy-api/scripts/scrape.js | 130 ++++++++++++++++++ .../parapharmacy-webhook-scraper.json | 22 +-- 2 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 apps/parapharmacy-api/scripts/scrape.js diff --git a/apps/parapharmacy-api/scripts/scrape.js b/apps/parapharmacy-api/scripts/scrape.js new file mode 100644 index 0000000..7c28e45 --- /dev/null +++ b/apps/parapharmacy-api/scripts/scrape.js @@ -0,0 +1,130 @@ +import axios from 'axios'; + +const API_URL = process.env.PARAPHARMACY_API_URL || 'http://localhost:3002'; + +const QUERIES = ['crema hidratante', 'protector solar', 'vitaminas', 'capricare']; + +const SOURCES = { + promofarma: (q) => `https://www.promofarma.com/es/search?q=${encodeURIComponent(q)}`, + pharmarket: (q) => `https://www.pharmarket.es/catalogsearch/result/?q=${encodeURIComponent(q)}`, +}; + +async function scrapeSource(source, url) { + try { + console.log(` 🔍 Scraping ${source}: ${url}`); + const response = await axios.get(url, { + timeout: 15000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + return response.data; + } catch (error) { + console.error(` ❌ Error scraping ${source}: ${error.message}`); + return null; + } +} + +function extractProducts(html, source) { + const products = []; + + // Try multiple patterns + const patterns = [ + // Promofarma patterns + /]*class="[^"]*product-card[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/gi, + /]*class="[^"]*product[^"]*"[^>]*>([\s\S]*?)<\/article>/gi, + // Generic patterns + /<(?:div|li)[^>]*class="[^"]*(?:product|item)[^"]*"[^>]*>([\s\S]*?)<\/(?:div|li)>/gi, + ]; + + const namePatterns = [ + /]*>([^<]+)<\/h[23]>/i, + /class="[^"]*(?:name|title)[^"]*"[^>]*>([^<]+)]*>([^<]*\d+[.,]\d+[^<]*) 3 && name.length < 200 && price > 0 && price < 1000) { + products.push({ + name: name.substring(0, 200), + price, + source, + source_product_id: `${source}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`, + source_url: `https://www.${source}.com`, + brand: '', + category: 'parapharmacy', + available: true, + scraped_at: new Date().toISOString() + }); + } + } + } + + // Deduplicate + const seen = new Set(); + return products.filter(p => { + const key = `${source}:${p.name.toLowerCase()}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }).slice(0, 5); +} + +async function main() { + console.log('🚀 Starting parapharmacy scraper...\n'); + + let totalProducts = 0; + + for (const query of QUERIES) { + console.log(`\n📋 Query: "${query}"`); + + for (const [source, urlFn] of Object.entries(SOURCES)) { + const url = urlFn(query); + const html = await scrapeSource(source, url); + + if (html) { + const products = extractProducts(html, source); + console.log(` ✅ Found ${products.length} products`); + + if (products.length > 0) { + try { + await axios.post(`${API_URL}/api/products/bulk`, { products }); + totalProducts += products.length; + } catch (error) { + console.error(` ❌ Error sending to API: ${error.message}`); + } + } + } + } + } + + console.log(`\n📊 Scraping completed. Total products: ${totalProducts}`); +} + +main().catch(error => { + console.error('❌ Scraper failed:', error.message); + process.exit(1); +}); diff --git a/n8n/workflows/parapharmacy-webhook-scraper.json b/n8n/workflows/parapharmacy-webhook-scraper.json index c9bac10..68305a6 100644 --- a/n8n/workflows/parapharmacy-webhook-scraper.json +++ b/n8n/workflows/parapharmacy-webhook-scraper.json @@ -1,10 +1,11 @@ { - "name": "Parapharmacy Manual Scraper (All Sources)", + "name": "Parapharmacy Manual Scraper", "nodes": [ { "parameters": { "httpMethod": "POST", "path": "scrape-all", + "responseMode": "lastNode", "options": {} }, "id": "webhook", @@ -16,7 +17,7 @@ }, { "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 } }];" + "jsCode": "const body = $input.first().json.body || {};\nconst queries = body.queries || 'crema hidratante';\nconst sources = body.sources || ['promofarma'];\n\nreturn [{ json: { queries, sources } }];" }, "id": "parse-input", "name": "Parse Input", @@ -50,7 +51,7 @@ }, { "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 }));" + "jsCode": "const data = $input.first().json;\nconst html = data.data || '';\nconst source = data.source;\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", @@ -75,13 +76,12 @@ }, { "parameters": { - "respondWith": "json", - "responseBody": "={{ { success: true, message: 'Scraping completed', products: $input.all().length } }}" + "jsCode": "const result = $input.first().json;\nreturn [{ json: { success: true, message: 'Scraping completed', result } }];" }, - "id": "respond", - "name": "Respond", - "type": "n8n-nodes-base.respondToWebhook", - "typeVersion": 1.1, + "id": "format-response", + "name": "Format Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, "position": [1540, 300] } ], @@ -91,9 +91,9 @@ "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 }]] } + "Send to API": { "main": [[{ "node": "Format Response", "type": "main", "index": 0 }]] } }, "active": true, "settings": { "executionOrder": "v1" }, - "tags": [{ "name": "parapharmacy" }, { "name": "scraper" }, { "name": "webhook" }] + "tags": [{ "name": "parapharmacy" }, { "name": "scraper" }] } From a5a75d3249b2fa086abd31bc75da13790fbd5965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 14:54:51 +0200 Subject: [PATCH 11/25] feat: working Puppeteer scraper for Promofarma - Added Puppeteer with Chrome in Docker - Scraper extracts products using data attributes - Added /api/scrape endpoint - Tested: 10 products scraped from Promofarma The scraper now works with Promofarma's HTML structure which uses data-name, data-pvp attributes on article elements. --- apps/parapharmacy-api/Dockerfile | 27 ++- apps/parapharmacy-api/package.json | 2 + apps/parapharmacy-api/src/routes/scraper.js | 26 +++ apps/parapharmacy-api/src/scraper.js | 189 ++++++++++++++++++++ apps/parapharmacy-api/src/server.js | 2 + n8n/workflow-sdk.js | 145 +++++++++++++++ 6 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 apps/parapharmacy-api/src/routes/scraper.js create mode 100644 apps/parapharmacy-api/src/scraper.js create mode 100644 n8n/workflow-sdk.js diff --git a/apps/parapharmacy-api/Dockerfile b/apps/parapharmacy-api/Dockerfile index a29c043..87827e7 100644 --- a/apps/parapharmacy-api/Dockerfile +++ b/apps/parapharmacy-api/Dockerfile @@ -1,7 +1,32 @@ -FROM node:20-alpine +FROM node:20-slim WORKDIR /app +# Install Chrome and dependencies for Puppeteer +RUN apt-get update && apt-get install -y \ + chromium \ + fonts-liberation \ + libappindicator3-1 \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libcups2 \ + libdbus-1-3 \ + libgdk-pixbuf2.0-0 \ + libnspr4 \ + libnss3 \ + libx11-xcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + xdg-utils \ + --no-install-recommends && \ + rm -rf /var/lib/apt/lists/* + +# Set Puppeteer to use installed Chrome +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + # Copy package files COPY package*.json ./ diff --git a/apps/parapharmacy-api/package.json b/apps/parapharmacy-api/package.json index 79c42c4..14318e0 100644 --- a/apps/parapharmacy-api/package.json +++ b/apps/parapharmacy-api/package.json @@ -8,6 +8,7 @@ "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", + "scrape": "node --env-file-if-exists=.env scripts/scrape-puppeteer.js", "test": "NODE_OPTIONS='--experimental-vm-modules' npx jest --ci --forceExit --forceExitTimeout=30000" }, "dependencies": { @@ -16,6 +17,7 @@ "express-rate-limit": "^8.5.2", "mongoose": "^8.8.0", "morgan": "^1.10.0", + "puppeteer": "^22.0.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "axios": "^1.6.0" diff --git a/apps/parapharmacy-api/src/routes/scraper.js b/apps/parapharmacy-api/src/routes/scraper.js new file mode 100644 index 0000000..6145ed6 --- /dev/null +++ b/apps/parapharmacy-api/src/routes/scraper.js @@ -0,0 +1,26 @@ +import { Router } from 'express'; +import { scrapeAll } from '../scraper.js'; + +const router = Router(); + +// Trigger scraping +router.post('/scrape', async (req, res) => { + try { + const { queries = ['crema hidratante'], sources = ['promofarma'] } = req.body; + + console.log('[Scraper] Starting scrape...'); + console.log(`[Scraper] Queries: ${queries.join(', ')}`); + console.log(`[Scraper] Sources: ${sources.join(', ')}`); + + const result = await scrapeAll(queries, sources); + + console.log(`[Scraper] Completed. Total: ${result.total} products`); + + res.json(result); + } catch (error) { + console.error('[Scraper] Error:', error.message); + res.status(500).json({ error: 'Scraping failed', message: error.message }); + } +}); + +export default router; diff --git a/apps/parapharmacy-api/src/scraper.js b/apps/parapharmacy-api/src/scraper.js new file mode 100644 index 0000000..ca09f55 --- /dev/null +++ b/apps/parapharmacy-api/src/scraper.js @@ -0,0 +1,189 @@ +import puppeteer from 'puppeteer'; +import axios from 'axios'; + +const API_URL = process.env.PARAPHARMACY_API_URL || 'http://localhost:3002'; + +const SOURCES = { + promofarma: { + name: 'Promofarma', + searchUrl: (q) => `https://www.promofarma.com/es/search?q=${encodeURIComponent(q)}`, + selectors: { + product: 'article[data-name]', + name: 'article[data-name]', + price: 'article[data-pvp]', + link: 'a', + image: 'img' + } + }, + docmorris: { + name: 'DocMorris', + searchUrl: (q) => `https://www.docmorris.es/search?query=${encodeURIComponent(q)}`, + selectors: { + product: '[class*="product-card"], [class*="product-item"]', + name: '[class*="product-name"], h3', + price: '[class*="price"]', + link: 'a', + image: 'img' + } + }, + primor: { + name: 'Primor', + searchUrl: (q) => `https://www.primor.eu/search?s=${encodeURIComponent(q)}`, + selectors: { + product: '.product-item, .product-miniature', + name: '.product-title, h3', + price: '.price', + link: 'a.product-title', + image: 'img' + } + } +}; + +async function scrapeSource(browser, source, query) { + const config = SOURCES[source]; + if (!config) return []; + + const page = await browser.newPage(); + await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); + + try { + console.log(` 🔍 Scraping ${config.name}: ${query}`); + const url = config.searchUrl(query); + console.log(` 📍 URL: ${url}`); + + await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 }); + + // Wait for products to load + await page.waitForSelector(config.selectors.product, { timeout: 15000 }).catch(() => {}); + + // Get page title for debugging + const title = await page.title(); + console.log(` 📄 Page title: ${title}`); + + // Get page content for debugging + const content = await page.content(); + console.log(` 📄 Page length: ${content.length} chars`); + + // Try to find product elements with various selectors + const productInfo = await page.evaluate(() => { + // Try multiple selector strategies + const selectors = [ + '[data-testid*="product"]', + '[class*="ProductCard"]', + '[class*="product-card"]', + 'a[href*="/p/"]', + '[class*="Product"]', + 'article', + '[role="listitem"]' + ]; + + for (const sel of selectors) { + const els = document.querySelectorAll(sel); + if (els.length > 0) { + return { + selector: sel, + count: els.length, + samples: Array.from(els).slice(0, 3).map(el => ({ + tag: el.tagName, + class: el.className?.substring(0, 100), + html: el.outerHTML?.substring(0, 300) + })) + }; + } + } + + // Fallback: look for any element with price-like content + const allText = document.body.innerText; + const priceMatch = allText.match(/\d+[.,]\d{2}\s*€/g); + + return { + selector: 'none found', + priceMatches: priceMatch?.slice(0, 5) || [], + bodyTextSample: document.body.innerText.substring(0, 500) + }; + }); + console.log(` 🔍 Product info:`, JSON.stringify(productInfo, null, 2)); + + const products = await page.evaluate((selectors) => { + const results = []; + const cards = document.querySelectorAll(selectors.product); + + cards.forEach(card => { + // Try data attributes first (Promofarma style) + const name = card.getAttribute('data-name') || card.querySelector(selectors.name)?.innerText?.trim(); + const priceStr = card.getAttribute('data-pvp') || card.querySelector(selectors.price)?.innerText?.trim(); + const linkEl = card.querySelector(selectors.link); + const imgEl = card.querySelector(selectors.image); + + if (name && priceStr) { + const price = parseFloat(priceStr.replace(/[^0-9.,]/g, '').replace(',', '.')) || 0; + + if (name && price > 0 && price < 1000) { + results.push({ + name: name.substring(0, 200), + price, + source_url: linkEl?.href || '', + image_url: imgEl?.src || null + }); + } + } + }); + + return results; + }, config.selectors); + + console.log(` 📦 Found ${products.length} products`); + return products.slice(0, 10).map(p => ({ + ...p, + source, + source_product_id: `${source}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`, + brand: '', + category: 'parapharmacy', + available: true, + scraped_at: new Date().toISOString() + })); + + } catch (error) { + console.error(` ❌ Error scraping ${config.name}: ${error.message}`); + return []; + } finally { + await page.close(); + } +} + +export async function scrapeAll(queries = ['crema hidratante'], sources = ['promofarma']) { + console.log('🚀 Starting scraper...'); + + const browser = await puppeteer.launch({ + headless: 'new', + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'] + }); + + let totalProducts = 0; + + try { + for (const query of queries) { + console.log(`\n📋 Query: "${query}"`); + + for (const source of sources) { + const products = await scrapeSource(browser, source, query); + console.log(` ✅ Found ${products.length} products`); + + if (products.length > 0) { + try { + await axios.post(`${API_URL}/api/products/bulk`, { products }); + totalProducts += products.length; + } catch (error) { + console.error(` ❌ Error sending to API: ${error.message}`); + } + } + } + } + } finally { + await browser.close(); + } + + console.log(`\n📊 Scraping completed. Total: ${totalProducts} products`); + return { success: true, total: totalProducts }; +} diff --git a/apps/parapharmacy-api/src/server.js b/apps/parapharmacy-api/src/server.js index 92664d4..3a705c7 100644 --- a/apps/parapharmacy-api/src/server.js +++ b/apps/parapharmacy-api/src/server.js @@ -7,6 +7,7 @@ import swaggerJsdoc from 'swagger-jsdoc'; import swaggerUi from 'swagger-ui-express'; import { config } from './config.js'; import productsRouter from './routes/products.js'; +import scraperRouter from './routes/scraper.js'; const app = express(); @@ -69,6 +70,7 @@ app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { // Routes app.use('/api/products', productsRouter); +app.use('/api', scraperRouter); // Health check app.get('/api/health', (req, res) => { diff --git a/n8n/workflow-sdk.js b/n8n/workflow-sdk.js new file mode 100644 index 0000000..cbe78b4 --- /dev/null +++ b/n8n/workflow-sdk.js @@ -0,0 +1,145 @@ +import { workflow, node, trigger } from '@n8n/workflow-sdk'; + +const webhook = trigger({ + type: 'n8n-nodes-base.webhook', + version: 2, + config: { + name: 'Webhook', + position: [240, 300], + parameters: { + httpMethod: 'POST', + path: 'scrape-fixed', + responseMode: 'lastNode' + } + }, + output: [{}] +}); + +const parseInput = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Parse Input', + position: [440, 300], + parameters: { + jsCode: `const body = $input.first().json.body || {}; +return [{ json: { queries: body.queries || 'crema hidratante', sources: body.sources || ['promofarma'] } }];` + } + }, + output: [{}] +}); + +const generateTasks = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Generate Tasks', + position: [640, 300], + parameters: { + jsCode: `const input = $input.first().json; +const queries = input.queries.split(',').map(q => q.trim()); +const tasks = []; +for (const q of queries) { + tasks.push({ query: q, source: 'promofarma', url: 'https://www.promofarma.com/es/search?q=' + encodeURIComponent(q) }); +} +return tasks.map(t => ({ json: t }));` + } + }, + output: [{}] +}); + +const scrape = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.2, + config: { + name: 'Scrape', + position: [840, 300], + parameters: { + method: 'GET', + url: '={{ $json.url }}', + options: { timeout: 30000 } + } + }, + output: [{}] +}); + +const extractProducts = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Extract Products', + position: [1040, 300], + parameters: { + jsCode: `const data = $input.first().json; +const html = data.data || ''; +const source = data.source; +const products = []; +const cardRegex = /<(?:div|article)[^>]*class="[^"]*product[^"]*"[^>]*>([\\s\\S]*?)<\\/(?:div|article)>/gi; +const nameRegex = /]*>([^<]+)<\\/h[23]>/i; +const priceRegex = /class="[^"]*price[^"]*"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i; +let match; +while ((match = cardRegex.exec(html)) !== null) { + const card = match[1]; + const name = nameRegex.exec(card)?.[1]?.trim(); + const priceStr = priceRegex.exec(card)?.[1]?.trim(); + if (name && name.length > 3) { + const price = parseFloat(priceStr?.replace(/[^\\d.,]/g, '').replace(',', '.')) || 0; + if (price > 0 && price < 1000) { + products.push({ + name: name.substring(0, 200), + price, + source, + source_product_id: source + '_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6), + source_url: 'https://www.' + source + '.com', + brand: '', + category: 'parapharmacy', + available: true, + scraped_at: new Date().toISOString() + }); + } + } +} +return products.slice(0, 5).map(p => ({ json: p }));` + } + }, + output: [{}] +}); + +const sendToApi = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.2, + config: { + name: 'Send to API', + position: [1240, 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) }) }}' + } + }, + output: [{}] +}); + +const response = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Response', + position: [1440, 300], + parameters: { + jsCode: `return [{ json: { success: true, message: 'Scraping completed', products: $input.all().length } }];` + } + }, + output: [{}] +}); + +export default workflow('scrape-fixed', 'Parapharmacy Scraper Fixed') + .add(webhook) + .to(parseInput) + .to(generateTasks) + .to(scrape) + .to(extractProducts) + .to(sendToApi) + .to(response); From 972a59662d1207f2a389c13edbf31204223e671b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 15:03:46 +0200 Subject: [PATCH 12/25] fix: fix parapharmacy proxy route order and add axios import - Moved parapharmacy routes before :source/:id routes to prevent conflicts - Added axios import to server.js - Backend proxy now correctly forwards to parapharmacy-api - Tested: search for 'capricare' returns 1 result, 'crema' returns 7 results --- apps/backend/server.js | 119 +++++++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 59 deletions(-) diff --git a/apps/backend/server.js b/apps/backend/server.js index f2e5c7c..c0dc419 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -5,6 +5,7 @@ if (process.env.NODE_ENV !== 'test') { } import express from 'express'; +import axios from 'axios'; import redisClient from './redis-client.js'; import * as appMetrics from './src/metrics.js'; import cors from 'cors'; @@ -679,6 +680,65 @@ app.get('/api/medicines/:medicineId', async (req, res) => { } }); +// ========== PARAPHARMACY PROXY ========== + +const PARAPHARMACY_API_URL = process.env.PARAPHARMACY_API_URL || 'http://localhost:3002'; + +// Search parapharmacy products (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/search', searchLimiter, async (req, res) => { + try { + const { q, category, brand, page = 1, limit = 20 } = req.query; + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (category) params.set('category', category); + if (brand) params.set('brand', brand); + params.set('page', page); + params.set('limit', limit); + + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/search?${params}`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Search error:', error.message); + res.status(500).json({ error: 'Error searching parapharmacy products' }); + } +}); + +// Get parapharmacy product details (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/:id', async (req, res) => { + try { + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/${req.params.id}`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Detail error:', error.message); + if (error.response?.status === 404) { + return res.status(404).json({ error: 'Product not found' }); + } + res.status(500).json({ error: 'Error fetching product details' }); + } +}); + +// Get parapharmacy categories (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/categories', async (req, res) => { + try { + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/categories`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Categories error:', error.message); + res.status(500).json({ error: 'Error fetching categories' }); + } +}); + +// Get parapharmacy brands (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/brands', async (req, res) => { + try { + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/brands`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Brands error:', error.message); + res.status(500).json({ error: 'Error fetching brands' }); + } +}); + // ========== OTC PRODUCT SEARCH (CIMA only) ========== app.get('/api/products/search', searchLimiter, async (req, res) => { @@ -765,65 +825,6 @@ app.get('/api/products/:source/:productId/pharmacies', async (req, res) => { } }); -// ========== PARAPHARMACY PROXY ========== - -const PARAPHARMACY_API_URL = process.env.PARAPHARMACY_API_URL || 'http://localhost:3002'; - -// Search parapharmacy products (proxy to parapharmacy-api) -app.get('/api/products/parapharmacy/search', searchLimiter, async (req, res) => { - try { - const { q, category, brand, page = 1, limit = 20 } = req.query; - const params = new URLSearchParams(); - if (q) params.set('q', q); - if (category) params.set('category', category); - if (brand) params.set('brand', brand); - params.set('page', page); - params.set('limit', limit); - - const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/search?${params}`); - res.json(response.data); - } catch (error) { - console.error('[Parapharmacy] Search error:', error.message); - res.status(500).json({ error: 'Error searching parapharmacy products' }); - } -}); - -// Get parapharmacy product details (proxy to parapharmacy-api) -app.get('/api/products/parapharmacy/:id', async (req, res) => { - try { - const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/${req.params.id}`); - res.json(response.data); - } catch (error) { - console.error('[Parapharmacy] Detail error:', error.message); - if (error.response?.status === 404) { - return res.status(404).json({ error: 'Product not found' }); - } - res.status(500).json({ error: 'Error fetching product details' }); - } -}); - -// Get parapharmacy categories (proxy to parapharmacy-api) -app.get('/api/products/parapharmacy/categories', async (req, res) => { - try { - const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/categories`); - res.json(response.data); - } catch (error) { - console.error('[Parapharmacy] Categories error:', error.message); - res.status(500).json({ error: 'Error fetching categories' }); - } -}); - -// Get parapharmacy brands (proxy to parapharmacy-api) -app.get('/api/products/parapharmacy/brands', async (req, res) => { - try { - const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/brands`); - res.json(response.data); - } catch (error) { - console.error('[Parapharmacy] Brands error:', error.message); - res.status(500).json({ error: 'Error fetching brands' }); - } -}); - // ========== AUTHENTICATION MIDDLEWARE ========== // Middleware to check if user is authenticated From 05b99e9ed43ee42ef9ad7b80f9282fb6598ee38e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 15:19:07 +0200 Subject: [PATCH 13/25] feat: working scraping for Promofarma, DocMorris, and Primor - Fixed Primor URL format and selectors - Created simple N8N webhook that calls scraper API - All 3 sources now working: - Promofarma: 20 products - DocMorris: 10 products - Primor: 20 products - Total: 130 products in database - N8N webhook at /webhook/scrape working correctly --- apps/parapharmacy-api/src/scraper.js | 34 +++++++++++++------ n8n/workflow-sdk-simple.js | 51 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 n8n/workflow-sdk-simple.js diff --git a/apps/parapharmacy-api/src/scraper.js b/apps/parapharmacy-api/src/scraper.js index ca09f55..9838d83 100644 --- a/apps/parapharmacy-api/src/scraper.js +++ b/apps/parapharmacy-api/src/scraper.js @@ -28,12 +28,12 @@ const SOURCES = { }, primor: { name: 'Primor', - searchUrl: (q) => `https://www.primor.eu/search?s=${encodeURIComponent(q)}`, + searchUrl: (q) => `https://www.primor.eu/catalogsearch/result/?q=${encodeURIComponent(q)}`, selectors: { - product: '.product-item, .product-miniature', - name: '.product-title, h3', - price: '.price', - link: 'a.product-title', + product: 'form.product-item', + name: 'form.product-item', + price: 'form.product-item', + link: 'a', image: 'img' } } @@ -74,7 +74,10 @@ async function scrapeSource(browser, source, query) { 'a[href*="/p/"]', '[class*="Product"]', 'article', - '[role="listitem"]' + '[role="listitem"]', + '.product-item', + '.product', + '[data-product]' ]; for (const sel of selectors) { @@ -85,8 +88,8 @@ async function scrapeSource(browser, source, query) { count: els.length, samples: Array.from(els).slice(0, 3).map(el => ({ tag: el.tagName, - class: el.className?.substring(0, 100), - html: el.outerHTML?.substring(0, 300) + class: el.className?.substring(0, 150), + html: el.outerHTML?.substring(0, 500) })) }; } @@ -110,8 +113,19 @@ async function scrapeSource(browser, source, query) { cards.forEach(card => { // Try data attributes first (Promofarma style) - const name = card.getAttribute('data-name') || card.querySelector(selectors.name)?.innerText?.trim(); - const priceStr = card.getAttribute('data-pvp') || card.querySelector(selectors.price)?.innerText?.trim(); + let name = card.getAttribute('data-name'); + let priceStr = card.getAttribute('data-pvp'); + + // If no data attributes, try to extract from content + if (!name) { + const nameEl = card.querySelector('[class*="name"], h3, h2, .product-name'); + name = nameEl?.innerText?.trim(); + } + if (!priceStr) { + const priceEl = card.querySelector('[class*="price"], .price'); + priceStr = priceEl?.innerText?.trim(); + } + const linkEl = card.querySelector(selectors.link); const imgEl = card.querySelector(selectors.image); diff --git a/n8n/workflow-sdk-simple.js b/n8n/workflow-sdk-simple.js new file mode 100644 index 0000000..fd5e81b --- /dev/null +++ b/n8n/workflow-sdk-simple.js @@ -0,0 +1,51 @@ +import { workflow, node, trigger } from '@n8n/workflow-sdk'; + +const webhook = trigger({ + type: 'n8n-nodes-base.webhook', + version: 2, + config: { + name: 'Webhook', + position: [240, 300], + parameters: { + httpMethod: 'POST', + path: 'scrape', + responseMode: 'lastNode' + } + }, + output: [{}] +}); + +const callApi = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.2, + config: { + name: 'Call Scraper API', + position: [440, 300], + parameters: { + method: 'POST', + url: 'http://parapharmacy-api:3002/api/scrape', + sendBody: true, + specifyBody: 'json', + jsonBody: '={{ JSON.stringify($json.body || { queries: ["capricare"], sources: ["promofarma"] }) }}' + } + }, + output: [{}] +}); + +const response = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Response', + position: [640, 300], + parameters: { + jsCode: `return [{ json: $input.first().json }];` + } + }, + output: [{}] +}); + +export default workflow('scrape-simple', 'Parapharmacy Scraper Simple') + .add(webhook) + .to(callApi) + .to(response); From 570d4ab43e8838ebd8f2f516df5110c2d73fdc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 15:36:08 +0200 Subject: [PATCH 14/25] feat: unified search - remove filter tabs, show all results together - Frontend now searches both CIMA and Parapharmacy simultaneously - Removed filter tabs (Todos/Medicamentos/Parafarmacia) - Results show in a single unified list - Mobile: Shows medicines first, then parapharmacy section - Web: Same unified approach with results summary --- apps/frontend-mobile/app/(tabs)/search.tsx | 57 ++++++------------- apps/frontend/src/views/SearchView.jsx | 66 +++++++--------------- 2 files changed, 36 insertions(+), 87 deletions(-) diff --git a/apps/frontend-mobile/app/(tabs)/search.tsx b/apps/frontend-mobile/app/(tabs)/search.tsx index 8ce000b..1b95b2c 100644 --- a/apps/frontend-mobile/app/(tabs)/search.tsx +++ b/apps/frontend-mobile/app/(tabs)/search.tsx @@ -33,7 +33,6 @@ export default function SearchScreen() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [products, setProducts] = useState([]); - const [searchMode, setSearchMode] = useState<'all' | 'medicines' | 'products'>('all'); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -104,17 +103,11 @@ export default function SearchScreen() { onChangeText={setQuery} /> - {query.length >= 2 && ( - - setSearchMode('all')}> - Todos - - setSearchMode('medicines')}> - Medicamentos - - setSearchMode('products')}> - Parafarmacia - + {query.length >= 2 && (results.length + products.length) > 0 && ( + + + {results.length + products.length} resultados encontrados + )} @@ -176,29 +169,29 @@ export default function SearchScreen() { )} item.nregistro} renderItem={({ item }) => } ListHeaderComponent={ <> - {(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( + {products.length > 0 && ( - Parafarmacia y Bebé + Parafarmacia {products.map((product) => ( router.push(`/product/${product.source}/${product.id}`)} + onPress={() => router.push(`/product/${product.source}/${product.id || product._id}`)} activeOpacity={0.7} > - - {product.source === 'cima' ? 'CIMA' : 'OFF'} + + Parafarmacia {product.name} - {product.brand} + {product.brand} • {product.price}€ ))} @@ -217,29 +210,13 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - filterContainer: { - flexDirection: 'row', - justifyContent: 'center', - gap: spacing.sm, + resultsSummary: { + paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, }, - filterTab: { - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderRadius: borderRadius.full, - backgroundColor: 'rgba(0,0,0,0.05)', - }, - filterTabActive: { - backgroundColor: '#7fbf8f', - }, - filterText: { + resultsSummaryText: { fontSize: 14, - fontWeight: '600', - color: '#41493e', - }, - filterTextActive: { - color: '#ffffff', + fontWeight: '500', }, section: { paddingHorizontal: spacing.lg, diff --git a/apps/frontend/src/views/SearchView.jsx b/apps/frontend/src/views/SearchView.jsx index d711f51..f03e79d 100644 --- a/apps/frontend/src/views/SearchView.jsx +++ b/apps/frontend/src/views/SearchView.jsx @@ -18,7 +18,6 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate const [searchQuery, setSearchQuery] = useState(initialQuery); const [medicines, setMedicines] = useState([]); const [products, setProducts] = useState([]); - const [searchMode, setSearchMode] = useState('all'); const [selectedMedicine, setSelectedMedicine] = useState(null); const [pharmacies, setPharmacies] = useState([]); const [loading, setLoading] = useState(false); @@ -88,16 +87,24 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate const query = searchQuery.trim(); try { - // Only search medications from CIMA - const medicinesRes = await fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`); + // Search both CIMA and Parapharmacy simultaneously + const [medicinesRes, productsRes] = await Promise.allSettled([ + fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`), + fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(query)}`) + ]); // Only update if this search is still the current one if (query !== searchQuery.trim()) return; - if (medicinesRes.ok) { - const medicinesData = await medicinesRes.json(); + if (medicinesRes.status === 'fulfilled' && medicinesRes.value.ok) { + const medicinesData = await medicinesRes.value.json(); setMedicines(medicinesData); } + + if (productsRes.status === 'fulfilled' && productsRes.value.ok) { + const productsData = await productsRes.value.json(); + setProducts(productsData.results || []); + } } catch (error) { console.error('Search error:', error); } finally { @@ -108,26 +115,6 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate return () => clearTimeout(timeoutId); }, [searchQuery]); - // Search parapharmacy when user switches to Parafarmacia tab - useEffect(() => { - if (searchMode !== 'products' || searchQuery.trim().length < 2) return; - - const searchProducts = async () => { - const query = searchQuery.trim(); - try { - const response = await fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(query)}`); - if (response.ok) { - const data = await response.json(); - setProducts(data.results || []); - } - } catch (error) { - console.error('Parapharmacy search error:', error); - } - }; - - searchProducts(); - }, [searchMode, searchQuery]); - useEffect(() => { const fetchPharmacies = async () => { if (!selectedMedicine) { @@ -310,28 +297,13 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate {searchQuery && !selectedMedicine && ( <> -
- - - +
+ {(medicines.length + products.length) > 0 && ( + {medicines.length + products.length} resultados encontrados + )}
- {(searchMode === 'all' || searchMode === 'medicines') && ( + {medicines.length > 0 && ( { @@ -344,9 +316,9 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate /> )} - {(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( + {products.length > 0 && (
-

Parafarmacia y Bebé

+

Parafarmacia

{ From 3429b7e54839733d10684698d1edf6313314921e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 15:47:14 +0200 Subject: [PATCH 15/25] fix: product detail view for parapharmacy products - Updated ProductView to use correct endpoint for parapharmacy - Updated getProduct service to handle parapharmacy products - Shows price, category, brand, source for parapharmacy products - Updated labels from 'OFF' to 'Parafarmacia' --- .../app/product/[source]/[id].tsx | 26 ++++++++-------- apps/frontend-mobile/services/products.ts | 7 ++++- apps/frontend/src/views/ProductView.jsx | 31 ++++++++++++------- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/apps/frontend-mobile/app/product/[source]/[id].tsx b/apps/frontend-mobile/app/product/[source]/[id].tsx index 3659e67..c5f6557 100644 --- a/apps/frontend-mobile/app/product/[source]/[id].tsx +++ b/apps/frontend-mobile/app/product/[source]/[id].tsx @@ -62,7 +62,7 @@ export default function ProductDetailScreen() { {product.name} - {isCima ? 'CIMA' : 'OFF'} + {isCima ? 'CIMA' : 'Parafarmacia'} {product.brand ? ( @@ -92,21 +92,21 @@ export default function ProductDetailScreen() { ) : ( - Información nutricional - {product.nutriscore && ( - + Información del producto + {product.price != null && ( + )} - {product.nova_group != null && ( - + {product.original_price != null && product.original_price > (product.price || 0) && ( + )} - {product.eco_score && ( - + {product.category && ( + )} - {product.ingredients && ( - - Ingredientes - {product.ingredients} - + {product.brand && ( + + )} + {product.source_url && ( + )} )} diff --git a/apps/frontend-mobile/services/products.ts b/apps/frontend-mobile/services/products.ts index 43f85ac..9da3168 100644 --- a/apps/frontend-mobile/services/products.ts +++ b/apps/frontend-mobile/services/products.ts @@ -105,7 +105,12 @@ export async function getParapharmacyBrands(): Promise { export async function getProduct(source: string, id: string): Promise { try { - const { data } = await api.get(`/products/${source}/${id}`); + // Use different endpoint for parapharmacy products + const endpoint = source === 'parapharmacy' + ? `/products/parapharmacy/${id}` + : `/products/${source}/${id}`; + + const { data } = await api.get(endpoint); return data; } catch (error) { console.error('[Products] Detail error:', error); diff --git a/apps/frontend/src/views/ProductView.jsx b/apps/frontend/src/views/ProductView.jsx index 7f31f36..64554ea 100644 --- a/apps/frontend/src/views/ProductView.jsx +++ b/apps/frontend/src/views/ProductView.jsx @@ -17,13 +17,18 @@ export default function ProductView({ source, id, onBack }) { setError(null); setPharmacies([]); try { - const response = await fetch(`/api/products/${source}/${id}`); + // Use different endpoint for parapharmacy products + const apiUrl = source === 'parapharmacy' + ? `/api/products/parapharmacy/${id}` + : `/api/products/${source}/${id}`; + + const response = await fetch(apiUrl); if (!response.ok) { throw new Error('Producto no encontrado'); } const data = await response.json(); setProduct(data); - loadPharmacies(source, data.id); + loadPharmacies(source, data.id || data._id); } catch (err) { setError(err.message); } finally { @@ -71,8 +76,9 @@ export default function ProductView({ source, id, onBack }) { if (!product) return null; const isCima = product.source === 'cima'; + const isParapharmacy = product.source !== 'cima'; const sourceColor = isCima ? '#2563eb' : '#16a34a'; - const sourceLabel = isCima ? 'CIMA' : 'Open Food Facts'; + const sourceLabel = isCima ? 'CIMA' : 'Parafarmacia'; return (
@@ -116,17 +122,20 @@ export default function ProductView({ source, id, onBack }) { ) : ( <> - {product.nutriscore && product.nutriscore !== 'not-applicable' && product.nutriscore !== 'unknown' && ( - + {product.price && ( + )} - {product.nova_group && ( - + {product.original_price && product.original_price > product.price && ( + )} - {product.eco_score && product.eco_score !== 'unknown' && ( - + {product.category && ( + )} - {product.ingredients && ( - + {product.brand && ( + + )} + {product.source_url && ( + )} )} From 3666f4dee850ec40ea5fa872d42d6a5e4aca51e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 15:58:35 +0200 Subject: [PATCH 16/25] fix: update ProductResults and admin panel for parapharmacy - Updated ProductResults component with new source labels - Added price display for parapharmacy products - Updated admin panel to search both CIMA and Parapharmacy - Updated getSourceBadge to handle parapharmacy sources - Added CSS for product price display --- .../src/components/ProductResults.css | 6 ++ .../src/components/ProductResults.jsx | 21 ++++- .../components/admin/PharmacyProductLink.jsx | 94 +++++++++++++------ 3 files changed, 87 insertions(+), 34 deletions(-) diff --git a/apps/frontend/src/components/ProductResults.css b/apps/frontend/src/components/ProductResults.css index fb17ac6..fdb2a84 100644 --- a/apps/frontend/src/components/ProductResults.css +++ b/apps/frontend/src/components/ProductResults.css @@ -130,6 +130,12 @@ font-weight: 600; } +.product-price { + color: var(--primary); + font-weight: 700; + font-size: 1.1rem; +} + .product-card-footer { padding-top: 0.75rem; border-top: 1px solid var(--outline-variant); diff --git a/apps/frontend/src/components/ProductResults.jsx b/apps/frontend/src/components/ProductResults.jsx index db9c680..c7973d1 100644 --- a/apps/frontend/src/components/ProductResults.jsx +++ b/apps/frontend/src/components/ProductResults.jsx @@ -3,19 +3,27 @@ import './ProductResults.css'; const categoryLabels = { otc: 'Sin Receta', - baby_food: 'Alimentación Infantil', - baby_milk: 'Leche de Fórmula', - baby_cereal: 'Cereales Bebé' + parapharmacy: 'Parafarmacia', + dermocosmética: 'Dermocosmética', + 'Fórmulas lácteas': 'Fórmulas lácteas', + vitaminas: 'Vitaminas', + analgésicos: 'Analgésicos' }; const sourceColors = { cima: '#2563eb', - openfoodfacts: '#16a34a' + promofarma: '#16a34a', + docmorris: '#e11d48', + primor: '#7c3aed', + parapharmacy: '#16a34a' }; const sourceLabels = { cima: 'CIMA', - openfoodfacts: 'Open Food Facts' + promofarma: 'Promofarma', + docmorris: 'DocMorris', + primor: 'Primor', + parapharmacy: 'Parafarmacia' }; function ProductResults({ products, onSelect }) { @@ -95,6 +103,9 @@ function ProductCard({ product, onSelect }) { {product.brand && (

Marca: {product.brand}

)} + {product.price != null && ( +

Precio: {product.price} €

+ )} {product.source === 'cima' && product.active_ingredient && (

Principio Activo: {product.active_ingredient}

)} diff --git a/apps/frontend/src/components/admin/PharmacyProductLink.jsx b/apps/frontend/src/components/admin/PharmacyProductLink.jsx index dad7ac0..fe6ea73 100644 --- a/apps/frontend/src/components/admin/PharmacyProductLink.jsx +++ b/apps/frontend/src/components/admin/PharmacyProductLink.jsx @@ -47,7 +47,7 @@ function PharmacyProductLink() { } }, [selectedPharmacy]); - // Buscar productos en la API mientras el usuario escribe + // Buscar productos en ambas APIs mientras el usuario escribe useEffect(() => { const q = productSearch.trim(); if (q.length < 2) { @@ -60,12 +60,27 @@ function PharmacyProductLink() { const timeoutId = setTimeout(async () => { setSearching(true); try { - const response = await fetch(`/api/products/search?q=${encodeURIComponent(q)}`, { - credentials: 'include', - signal: controller.signal, - }); - const data = await response.json(); - setProductResults(Array.isArray(data) ? data : []); + // Search both CIMA and Parapharmacy + const [cimaRes, paraRes] = await Promise.allSettled([ + fetch(`/api/products/search?q=${encodeURIComponent(q)}`, { + credentials: 'include', + signal: controller.signal, + }), + fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(q)}&limit=10`, { + credentials: 'include', + signal: controller.signal, + }) + ]); + + const cimaData = cimaRes.status === 'fulfilled' ? await cimaRes.value.json() : []; + const paraData = paraRes.status === 'fulfilled' ? await paraRes.value.json() : { results: [] }; + + const allResults = [ + ...(Array.isArray(cimaData) ? cimaData : []), + ...(paraData.results || []).map(p => ({ ...p, source: p.source || 'parapharmacy' })) + ]; + + setProductResults(allResults); } catch (error) { if (error.name === 'AbortError') return; console.error('Error searching products:', error); @@ -116,15 +131,15 @@ function PharmacyProductLink() { } try { - // Build identifier: source:id (e.g., openfoodfacts:9421025231209) - const identifier = selectedProduct._id - ? `${selectedProduct.source || 'openfoodfacts'}:${selectedProduct._id}` - : `${selectedProduct.source || 'openfoodfacts'}:${selectedProduct.id}`; + // Build identifier: source:id + const productId = selectedProduct._id || selectedProduct.id; + const source = selectedProduct.source || 'parapharmacy'; + const identifier = `${source}:${productId}`; const payload = { pharmacy_id: parseInt(formData.pharmacy_id), - product_source: selectedProduct.source || 'openfoodfacts', - product_off_id: selectedProduct._id || selectedProduct.id, + product_source: source, + product_off_id: productId, product_name: selectedProduct.product_name || selectedProduct.name, price: formData.price ? parseFloat(formData.price) : null, stock: formData.stock ? parseInt(formData.stock) : 0 @@ -221,10 +236,28 @@ function PharmacyProductLink() { }; const getSourceBadge = (source) => { - if (source === 'cima') { - return CIMA; - } - return OFF; + const colors = { + cima: '#2563eb', + promofarma: '#16a34a', + docmorris: '#e11d48', + primor: '#7c3aed', + parapharmacy: '#16a34a' + }; + const labels = { + cima: 'CIMA', + promofarma: 'Promofarma', + docmorris: 'DocMorris', + primor: 'Primor', + parapharmacy: 'Parafarmacia' + }; + return ( + + {labels[source] || source} + + ); }; return ( @@ -284,7 +317,7 @@ function PharmacyProductLink() {
- + {searching &&

Buscando...

} - + {productResults.length > 0 && !selectedProduct && (
{productResults.slice(0, 10).map((product) => ( -
selectProduct(product)} > {product.product_name || product.name} + {product.brand && - {product.brand}} {product.brands && - {product.brands}} - {product.quantity && ({product.quantity})} + {product.price != null && - {product.price}€} + {getSourceBadge(product.source || 'parapharmacy')}
))}
@@ -317,15 +352,16 @@ function PharmacyProductLink() {

✅ Selected: {selectedProduct.product_name || selectedProduct.name}

+ {selectedProduct.brand && `Marca: ${selectedProduct.brand} • `} {selectedProduct.brands && `Marca: ${selectedProduct.brands} • `} - {selectedProduct.quantity && `Cantidad: ${selectedProduct.quantity} • `} - {getSourceBadge(selectedProduct.source || 'openfoodfacts')} + {selectedProduct.price != null && `Precio: ${selectedProduct.price}€ • `} + {getSourceBadge(selectedProduct.source || 'parapharmacy')} {' '} - {selectedProduct._id || selectedProduct.id} + {selectedProduct._id || selectedProduct.id || selectedProduct.nregistro}

- + {sortByDistance && positionSource && ( + Usando tu ubicación + )} + {locationError && ( + + {locationError} + + + )}
+ + + + ) : null}
From fa74a6e06024812c358a49793ff29624510a09be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 17:03:21 +0200 Subject: [PATCH 24/25] fix: filter scraper results by search query The scraper was extracting ALL products from the page including navigation menu items. Now it filters products to only include those that contain the search query in their name. Before: Found 47 products (mostly navigation items) After: Found 2 Capricare products (filtered by query) --- apps/parapharmacy-api/src/scraper.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/parapharmacy-api/src/scraper.js b/apps/parapharmacy-api/src/scraper.js index 9838d83..3bb1c75 100644 --- a/apps/parapharmacy-api/src/scraper.js +++ b/apps/parapharmacy-api/src/scraper.js @@ -107,10 +107,13 @@ async function scrapeSource(browser, source, query) { }); console.log(` 🔍 Product info:`, JSON.stringify(productInfo, null, 2)); - const products = await page.evaluate((selectors) => { + const products = await page.evaluate((selectors, searchQuery) => { const results = []; const cards = document.querySelectorAll(selectors.product); + // Get the search query to filter relevant products + const query = searchQuery.toLowerCase(); + cards.forEach(card => { // Try data attributes first (Promofarma style) let name = card.getAttribute('data-name'); @@ -132,7 +135,9 @@ async function scrapeSource(browser, source, query) { if (name && priceStr) { const price = parseFloat(priceStr.replace(/[^0-9.,]/g, '').replace(',', '.')) || 0; - if (name && price > 0 && price < 1000) { + // Filter: only include products that contain the search query + const nameLower = name.toLowerCase(); + if (name && price > 0 && price < 1000 && nameLower.includes(query)) { results.push({ name: name.substring(0, 200), price, @@ -144,7 +149,7 @@ async function scrapeSource(browser, source, query) { }); return results; - }, config.selectors); + }, config.selectors, query); console.log(` 📦 Found ${products.length} products`); return products.slice(0, 10).map(p => ({ From 8763f540712227ef716c97f71a900b345d17fb83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 17:31:05 +0200 Subject: [PATCH 25/25] fix: sync package.json and fix test scripts - scraper: changed test script to echo (no tests) - parapharmacy-api: added --passWithNoTests to jest - frontend: changed vitest to vitest run (exit after tests) All tests now pass: - backend: 17 tests passed - frontend: 7 tests passed - parapharmacy-api: no tests (exits cleanly) - scraper: no tests (exits cleanly) --- apps/frontend/package.json | 2 +- apps/parapharmacy-api/package.json | 2 +- apps/scraper/package.json | 2 +- package-lock.json | 101 +++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 9c8132a..5b0462f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,7 +6,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "test": "vitest" + "test": "vitest run" }, "dependencies": { "@capacitor-mlkit/barcode-scanning": "^8.1.0", diff --git a/apps/parapharmacy-api/package.json b/apps/parapharmacy-api/package.json index 14318e0..ba0b7c0 100644 --- a/apps/parapharmacy-api/package.json +++ b/apps/parapharmacy-api/package.json @@ -9,7 +9,7 @@ "dev": "node --env-file-if-exists=.env --watch src/server.js", "seed": "node --env-file-if-exists=.env scripts/seed.js", "scrape": "node --env-file-if-exists=.env scripts/scrape-puppeteer.js", - "test": "NODE_OPTIONS='--experimental-vm-modules' npx jest --ci --forceExit --forceExitTimeout=30000" + "test": "NODE_OPTIONS='--experimental-vm-modules' npx jest --ci --forceExit --forceExitTimeout=30000 --passWithNoTests" }, "dependencies": { "cors": "^2.8.5", diff --git a/apps/scraper/package.json b/apps/scraper/package.json index d2c32d3..c71b122 100644 --- a/apps/scraper/package.json +++ b/apps/scraper/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "dev": "node index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"No tests configured\"" }, "keywords": [], "author": "", diff --git a/package-lock.json b/package-lock.json index cd11d50..1e48bff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1455,6 +1455,7 @@ "express-rate-limit": "^8.5.2", "mongoose": "^8.8.0", "morgan": "^1.10.0", + "puppeteer": "^22.0.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0" }, @@ -1463,6 +1464,87 @@ "supertest": "^7.2.2" } }, + "apps/parapharmacy-api/node_modules/@puppeteer/browsers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", + "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", + "dependencies": { + "debug": "^4.3.5", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.4.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "apps/parapharmacy-api/node_modules/chromium-bidi": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", + "integrity": "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==", + "dependencies": { + "mitt": "3.0.1", + "urlpattern-polyfill": "10.0.0", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "apps/parapharmacy-api/node_modules/devtools-protocol": { + "version": "0.0.1312386", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", + "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==" + }, + "apps/parapharmacy-api/node_modules/puppeteer": { + "version": "22.15.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.15.0.tgz", + "integrity": "sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q==", + "deprecated": "< 24.15.0 is no longer supported", + "hasInstallScript": true, + "dependencies": { + "@puppeteer/browsers": "2.3.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1312386", + "puppeteer-core": "22.15.0" + }, + "bin": { + "puppeteer": "lib/esm/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "apps/parapharmacy-api/node_modules/puppeteer-core": { + "version": "22.15.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz", + "integrity": "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==", + "dependencies": { + "@puppeteer/browsers": "2.3.0", + "chromium-bidi": "0.6.3", + "debug": "^4.3.6", + "devtools-protocol": "0.0.1312386", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "apps/parapharmacy-api/node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "apps/scraper": { "version": "1.0.0", "license": "ISC", @@ -25913,6 +25995,11 @@ "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==" }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, "node_modules/through2": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", @@ -26270,6 +26357,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -26417,6 +26513,11 @@ "requires-port": "^1.0.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", + "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",