Merge pull request 'Feat/parapharmacy api' (#38) from feat/parapharmacy-api into main
Build & Push Docker Images / Detect Changes (push) Successful in 12s
Build & Push Docker Images / Backend Tests (push) Successful in 1m41s
Build & Push Docker Images / Frontend Tests (push) Successful in 1m42s
Build & Push Docker Images / Build Backend (push) Successful in 29s
Build & Push Docker Images / Build Frontend (push) Successful in 1m0s
Build & Push Docker Images / Deploy (push) Successful in 6m59s

Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
2026-07-16 15:38:58 +00:00
43 changed files with 3961 additions and 549 deletions
+30
View File
@@ -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
+23
View File
@@ -28,6 +28,9 @@ jobs:
frontend-mobile:
- 'apps/frontend-mobile/**'
- 'packages/**'
parapharmacy-api:
- 'apps/parapharmacy-api/**'
- 'packages/**'
test-backend:
name: Backend Tests
@@ -85,3 +88,23 @@ jobs:
- name: Run Frontend Mobile Tests
run: npm test --workspace=frontend-mobile -- --ci
continue-on-error: true
test-parapharmacy-api:
name: Parapharmacy API Tests
needs: detect-changes
if: needs.detect-changes.outputs.parapharmacy-api == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Install parapharmacy-api native deps
run: npm rebuild sqlite3 bcrypt --workspace=farma-clic-parapharmacy-api
- name: Run Parapharmacy API Tests
run: npm test --workspace=farma-clic-parapharmacy-api
+122 -19
View File
@@ -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=<query>` - Search medicines (CIMA API, cached in Redis)
### Medicines (Backend - Port 3001)
**Public:**
- `GET /api/medicines/search?q=<query>` - 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=<query>` - 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=<query>` - 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
+2 -6
View File
@@ -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
-107
View File
@@ -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()
})
})
-5
View File
@@ -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: '',
-206
View File
@@ -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;
}
}
+72 -21
View File
@@ -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';
@@ -23,7 +24,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 +680,66 @@ app.get('/api/medicines/:medicineId', async (req, res) => {
}
});
// ========== UNIFIED PRODUCT SEARCH ==========
// ========== 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) => {
try {
@@ -689,18 +748,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 +768,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 +780,9 @@ app.get('/api/products/:source/:productId/pharmacies', async (req, res) => {
try {
const { source, productId } = req.params;
if (source !== 'cima' && source !== 'openfoodfacts') {
// Accept all valid sources
const validSources = ['cima', 'promofarma', 'docmorris', 'primor', 'parapharmacy', 'seed', 'manual'];
if (!validSources.includes(source)) {
return res.status(400).json({ error: 'Invalid source' });
}
@@ -2078,8 +2127,10 @@ 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"' });
// Allow CIMA and all parapharmacy sources
const validSources = ['cima', 'promofarma', 'docmorris', 'primor', 'parapharmacy', 'seed', 'manual'];
if (!validSources.includes(product_source)) {
return res.status(400).json({ error: `product_source must be one of: ${validSources.join(', ')}` });
}
const existing = await userDbGet(
+21 -44
View File
@@ -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';
@@ -33,7 +33,6 @@ export default function SearchScreen() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Medicine[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [searchMode, setSearchMode] = useState<'all' | 'medicines' | 'products'>('all');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -78,10 +77,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);
}
};
@@ -104,17 +103,11 @@ export default function SearchScreen() {
onChangeText={setQuery}
/>
{query.length >= 2 && (
<View style={styles.filterContainer}>
<TouchableOpacity style={[styles.filterTab, searchMode === 'all' && styles.filterTabActive]} onPress={() => setSearchMode('all')}>
<Text style={[styles.filterText, searchMode === 'all' && styles.filterTextActive]}>Todos</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.filterTab, searchMode === 'medicines' && styles.filterTabActive]} onPress={() => setSearchMode('medicines')}>
<Text style={[styles.filterText, searchMode === 'medicines' && styles.filterTextActive]}>Medicamentos</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.filterTab, searchMode === 'products' && styles.filterTabActive]} onPress={() => setSearchMode('products')}>
<Text style={[styles.filterText, searchMode === 'products' && styles.filterTextActive]}>Parafarmacia</Text>
</TouchableOpacity>
{query.length >= 2 && (results.length + products.length) > 0 && (
<View style={styles.resultsSummary}>
<Text style={[styles.resultsSummaryText, { color: colors.textSecondary }]}>
{results.length + products.length} resultados encontrados
</Text>
</View>
)}
@@ -176,29 +169,29 @@ export default function SearchScreen() {
)}
<FlatList
data={(searchMode === 'medicines' || searchMode === 'all') ? results : []}
data={results}
keyExtractor={(item) => item.nregistro}
renderItem={({ item }) => <MedicineCard medicine={item} />}
ListHeaderComponent={
<>
{(searchMode === 'all' || searchMode === 'products') && products.length > 0 && (
{products.length > 0 && (
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Parafarmacia y Bebé</Text>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Parafarmacia</Text>
{products.map((product) => (
<TouchableOpacity
key={`${product.source}-${product.id}`}
key={`${product.source}-${product.id || product._id}`}
style={[styles.productCard, { backgroundColor: colors.card, borderColor: colors.border }]}
onPress={() => router.push(`/product/${product.source}/${product.id}`)}
onPress={() => router.push(`/product/${product.source}/${product.id || product._id}`)}
activeOpacity={0.7}
>
<View style={styles.productInfo}>
<View style={styles.badges}>
<View style={[styles.sourceBadge, { backgroundColor: product.source === 'cima' ? '#2563eb' : '#16a34a' }]}>
<Text style={styles.badgeText}>{product.source === 'cima' ? 'CIMA' : 'OFF'}</Text>
<View style={[styles.sourceBadge, { backgroundColor: '#16a34a' }]}>
<Text style={styles.badgeText}>Parafarmacia</Text>
</View>
</View>
<Text style={[styles.productName, { color: colors.text }]} numberOfLines={1}>{product.name}</Text>
<Text style={[styles.productBrand, { color: colors.textSecondary }]} numberOfLines={1}>{product.brand}</Text>
<Text style={[styles.productBrand, { color: colors.textSecondary }]} numberOfLines={1}>{product.brand} • {product.price}€</Text>
</View>
</TouchableOpacity>
))}
@@ -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,
@@ -62,7 +62,7 @@ export default function ProductDetailScreen() {
<View style={styles.nameRow}>
<Text style={[styles.name, { color: colors.text }]}>{product.name}</Text>
<View style={[styles.badge, { backgroundColor: isCima ? '#2b5bb5' : '#4caf50' }]}>
<Text style={styles.badgeText}>{isCima ? 'CIMA' : 'OFF'}</Text>
<Text style={styles.badgeText}>{isCima ? 'CIMA' : 'Parafarmacia'}</Text>
</View>
</View>
{product.brand ? (
@@ -92,21 +92,21 @@ export default function ProductDetailScreen() {
</View>
) : (
<View style={[styles.infoSection, { backgroundColor: colors.card }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Información nutricional</Text>
{product.nutriscore && (
<InfoRow label="Nutri-Score" value={product.nutriscore.toUpperCase()} colors={colors} />
<Text style={[styles.sectionTitle, { color: colors.text }]}>Información del producto</Text>
{product.price != null && (
<InfoRow label="Precio" value={`${product.price} €`} colors={colors} />
)}
{product.nova_group != null && (
<InfoRow label="Grupo NOVA" value={String(product.nova_group)} colors={colors} />
{product.original_price != null && product.original_price > (product.price || 0) && (
<InfoRow label="Precio anterior" value={`${product.original_price} €`} colors={colors} />
)}
{product.eco_score && (
<InfoRow label="Eco-Score" value={product.eco_score.toUpperCase()} colors={colors} />
{product.category && (
<InfoRow label="Categoría" value={product.category} colors={colors} />
)}
{product.ingredients && (
<View style={[styles.infoRow, { borderBottomColor: colors.border }]}>
<Text style={[styles.infoLabel, { color: colors.textSecondary }]}>Ingredientes</Text>
<Text style={[styles.infoValueMultiline, { color: colors.text }]}>{product.ingredients}</Text>
</View>
{product.brand && (
<InfoRow label="Marca" value={product.brand} colors={colors} />
)}
{product.source_url && (
<InfoRow label="Fuente" value={product.source || 'Parafarmacia'} colors={colors} />
)}
</View>
)}
+75 -8
View File
@@ -2,11 +2,19 @@ import api from './api';
export interface Product {
id: string;
source: 'cima' | 'openfoodfacts';
_id?: string;
source: 'cima' | '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;
@@ -14,18 +22,15 @@ 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 {
results: Product[];
total: number;
sources: {
page?: number;
pages?: number;
sources?: {
cima: number;
openfoodfacts: number;
};
}
@@ -42,9 +47,71 @@ export async function searchProducts(query: string): Promise<Product[]> {
}
}
export async function searchParapharmacy(query: string, options?: {
category?: string;
brand?: string;
page?: number;
limit?: number;
}): Promise<ProductSearchResponse> {
if (!query || query.trim().length < 2) {
return { results: [], total: 0 };
}
try {
const params: Record<string, string | number> = { 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<ProductSearchResponse>('/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<Product | null> {
try {
const { data } = await api.get<Product>(`/products/parapharmacy/${id}`);
return data;
} catch (error) {
console.error('[Parapharmacy] Detail error:', error);
return null;
}
}
export async function getParapharmacyCategories(): Promise<string[]> {
try {
const { data } = await api.get<string[]>('/products/parapharmacy/categories');
return data || [];
} catch (error) {
console.error('[Parapharmacy] Categories error:', error);
return [];
}
}
export async function getParapharmacyBrands(): Promise<string[]> {
try {
const { data } = await api.get<string[]>('/products/parapharmacy/brands');
return data || [];
} catch (error) {
console.error('[Parapharmacy] Brands error:', error);
return [];
}
}
export async function getProduct(source: string, id: string): Promise<Product | null> {
try {
const { data } = await api.get<Product>(`/products/${source}/${id}`);
// Use parapharmacy endpoint for all non-CIMA sources
const isCima = source === 'cima';
const endpoint = isCima
? `/products/${source}/${id}`
: `/products/parapharmacy/${id}`;
const { data } = await api.get<Product>(endpoint);
return data;
} catch (error) {
console.error('[Products] Detail error:', error);
+1 -1
View File
@@ -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",
@@ -34,6 +34,15 @@ function PharmacyMap({ pharmacies }) {
<strong>{pharmacy.name}</strong><br />
{pharmacy.address}
{pharmacy.phone && <><br />{pharmacy.phone}</>}
<br />
<a
href={`https://www.google.com/maps/dir/?api=1&destination=${pharmacy.latitude},${pharmacy.longitude}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2563eb', marginTop: '8px', display: 'inline-block' }}
>
📍 Cómo llegar
</a>
</Popup>
</Marker>
))}
@@ -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);
@@ -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 }) {
@@ -31,7 +39,7 @@ function ProductResults({ products, onSelect }) {
<div className="product-results">
{products.map((product) => (
<ProductCard
key={product.id}
key={product._id || product.id}
product={product}
onSelect={onSelect}
/>
@@ -95,6 +103,9 @@ function ProductCard({ product, onSelect }) {
{product.brand && (
<p><strong>Marca:</strong> {product.brand}</p>
)}
{product.price != null && (
<p className="product-price"><strong>Precio:</strong> {product.price} €</p>
)}
{product.source === 'cima' && product.active_ingredient && (
<p><strong>Principio Activo:</strong> {product.active_ingredient}</p>
)}
@@ -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,31 @@ 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() : { results: [] };
const paraData = paraRes.status === 'fulfilled' ? await paraRes.value.json() : { results: [] };
// Handle both array and object responses
const cimaResults = Array.isArray(cimaData) ? cimaData : (cimaData.results || []);
const paraResults = Array.isArray(paraData) ? paraData : (paraData.results || []);
const allResults = [
...cimaResults,
...paraResults.map(p => ({ ...p, source: p.source || 'parapharmacy' }))
];
setProductResults(allResults);
} catch (error) {
if (error.name === 'AbortError') return;
console.error('Error searching products:', error);
@@ -116,15 +135,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 +240,28 @@ function PharmacyProductLink() {
};
const getSourceBadge = (source) => {
if (source === 'cima') {
return <span className="source-badge source-badge--cima">CIMA</span>;
}
return <span className="source-badge source-badge--off">OFF</span>;
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 (
<span
className="source-badge"
style={{ backgroundColor: colors[source] || '#6b7280' }}
>
{labels[source] || source}
</span>
);
};
return (
@@ -284,7 +321,7 @@ function PharmacyProductLink() {
</div>
<div className="form-group">
<label>Buscar Producto (Open Food Facts) *</label>
<label>Buscar Producto (CIMA / Parafarmacia) *</label>
<input
type="text"
value={productSearch}
@@ -292,22 +329,24 @@ function PharmacyProductLink() {
setProductSearch(e.target.value);
setSelectedProduct(null);
}}
placeholder="Escribe para buscar productos..."
placeholder="Escribe para buscar medicamentos o productos de parafarmacia..."
required
/>
{searching && <p className="loading-text">Buscando...</p>}
{productResults.length > 0 && !selectedProduct && (
<div className="medicine-search-results">
{productResults.slice(0, 10).map((product) => (
<div
key={product._id || product.id}
<div
key={product._id || product.id || product.nregistro}
className="search-result-item"
onClick={() => selectProduct(product)}
>
<strong>{product.product_name || product.name}</strong>
{product.brand && <span> - {product.brand}</span>}
{product.brands && <span> - {product.brands}</span>}
{product.quantity && <span> ({product.quantity})</span>}
{product.price != null && <span> - {product.price}€</span>}
{getSourceBadge(product.source || 'parapharmacy')}
</div>
))}
</div>
@@ -317,15 +356,16 @@ function PharmacyProductLink() {
<div className="selected-medicine-info">
<p>✅ Selected: <strong>{selectedProduct.product_name || selectedProduct.name}</strong></p>
<p className="medicine-details">
{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}
</p>
<button
type="button"
className="btn-small"
<button
type="button"
className="btn-small"
onClick={() => {
setSelectedProduct(null);
setProductSearch('');
+101 -34
View File
@@ -1,4 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import PharmacyMap from '../components/PharmacyMap';
import PharmacyList from '../components/PharmacyList';
import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo';
import './ProductView.css';
export default function ProductView({ source, id, onBack }) {
@@ -7,6 +10,50 @@ export default function ProductView({ source, id, onBack }) {
const [error, setError] = useState(null);
const [pharmacies, setPharmacies] = useState([]);
const [loadingPharmacies, setLoadingPharmacies] = useState(false);
const [sortByDistance, setSortByDistance] = useState(false);
const [userPosition, setUserPosition] = useState(null);
const [positionSource, setPositionSource] = useState(null);
const [locating, setLocating] = useState(false);
const [locationError, setLocationError] = useState('');
const hasSavedCoords = product?.latitude != null && product?.longitude != null;
const displayedPharmacies = useMemo(() => {
if (!sortByDistance || !userPosition) return pharmacies;
return [...pharmacies].sort((a, b) => {
if (a.latitude == null || a.longitude == null) return 1;
if (b.latitude == null || b.longitude == null) return -1;
return (
haversineKm(userPosition.lat, userPosition.lon, a.latitude, a.longitude) -
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
);
});
}, [pharmacies, sortByDistance, userPosition]);
const handleSortByDistance = async () => {
if (sortByDistance) {
setSortByDistance(false);
return;
}
setLocationError('');
setLocating(true);
try {
const pos = await getUserPosition();
setUserPosition(pos);
setPositionSource('browser');
setSortByDistance(true);
} catch (err) {
let msg = 'No se pudo obtener tu ubicación';
if (err && typeof err.code === 'number') {
if (err.code === 1) msg = 'Permiso de ubicación denegado.';
else if (err.code === 2) msg = 'Ubicación no disponible.';
else if (err.code === 3) msg = 'La ubicación tardó demasiado.';
}
setLocationError(msg);
} finally {
setLocating(false);
}
};
useEffect(() => {
loadProduct();
@@ -17,13 +64,19 @@ export default function ProductView({ source, id, onBack }) {
setError(null);
setPharmacies([]);
try {
const response = await fetch(`/api/products/${source}/${id}`);
// Use parapharmacy endpoint for all non-CIMA sources
const isCima = source === 'cima';
const apiUrl = isCima
? `/api/products/${source}/${id}`
: `/api/products/parapharmacy/${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 +124,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 (
<div className="product-view">
@@ -116,17 +170,20 @@ export default function ProductView({ source, id, onBack }) {
</>
) : (
<>
{product.nutriscore && product.nutriscore !== 'not-applicable' && product.nutriscore !== 'unknown' && (
<DetailRow label="Nutri-Score" value={product.nutriscore.toUpperCase()} />
{product.price && (
<DetailRow label="Precio" value={`${product.price} €`} />
)}
{product.nova_group && (
<DetailRow label="NOVA" value={`Grupo ${product.nova_group}`} />
{product.original_price && product.original_price > product.price && (
<DetailRow label="Precio anterior" value={`${product.original_price} €`} />
)}
{product.eco_score && product.eco_score !== 'unknown' && (
<DetailRow label="Eco-Score" value={product.eco_score.toUpperCase()} />
{product.category && (
<DetailRow label="Categoría" value={product.category} />
)}
{product.ingredients && (
<DetailRow label="Ingredientes" value={product.ingredients} />
{product.brand && (
<DetailRow label="Marca" value={product.brand} />
)}
{product.source_url && (
<DetailRow label="Fuente" value={product.source} />
)}
</>
)}
@@ -140,29 +197,39 @@ export default function ProductView({ source, id, onBack }) {
<h3 className="pharmacies-title">
Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'}
</h3>
<div className="pharmacies-grid">
{pharmacies.map((pharmacy) => (
<div key={pharmacy.id} className="product-pharmacy-card">
<h4 className="product-pharmacy-name">{pharmacy.name}</h4>
<p className="product-pharmacy-address">{pharmacy.address}</p>
{pharmacy.phone && (
<p className="product-pharmacy-phone">{pharmacy.phone}</p>
)}
<div className="product-pharmacy-status">
{pharmacy.price && (
<span className="product-pharmacy-price">
{parseFloat(pharmacy.price).toFixed(2)} &euro;
</span>
)}
{pharmacy.stock !== undefined && (
<span className={`product-pharmacy-stock ${pharmacy.stock > 20 ? 'in-stock' : pharmacy.stock > 0 ? 'low-stock' : 'out-of-stock'}`}>
{pharmacy.stock > 20 ? 'En Stock' : pharmacy.stock > 0 ? `Stock Bajo (${pharmacy.stock})` : 'Sin Stock'}
</span>
)}
</div>
</div>
))}
<div className="pharmacy-controls">
<button
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
onClick={handleSortByDistance}
disabled={locating}
>
{locating
? '📍 Localizando…'
: sortByDistance
? '📍 Ordenado por distancia · Reset'
: '📍 Ordenar por distancia'}
</button>
{sortByDistance && positionSource && (
<span className="location-source">Usando tu ubicación</span>
)}
{locationError && (
<span className="location-error">
{locationError}
<button className="retry-location-btn" onClick={handleSortByDistance}>
Reintentar
</button>
</span>
)}
</div>
<PharmacyMap pharmacies={displayedPharmacies} />
<PharmacyList
pharmacies={displayedPharmacies}
loading={loadingPharmacies}
userPosition={sortByDistance ? userPosition : null}
/>
</>
) : null}
</div>
+20 -9
View File
@@ -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) {
+12 -27
View File
@@ -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,10 +87,10 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
const query = searchQuery.trim();
try {
// Run both searches in parallel for speed
// Search both CIMA and Parapharmacy simultaneously
const [medicinesRes, productsRes] = await Promise.allSettled([
fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`),
fetch(`/api/products/search?q=${encodeURIComponent(query)}`),
fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(query)}`)
]);
// Only update if this search is still the current one
@@ -298,28 +297,13 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
{searchQuery && !selectedMedicine && (
<>
<div className="filter-tabs">
<button
className={`filter-tab ${searchMode === 'all' ? 'filter-tab--active' : ''}`}
onClick={() => setSearchMode('all')}
>
Todos ({medicines.length + products.length})
</button>
<button
className={`filter-tab ${searchMode === 'medicines' ? 'filter-tab--active' : ''}`}
onClick={() => setSearchMode('medicines')}
>
Medicamentos ({medicines.length})
</button>
<button
className={`filter-tab ${searchMode === 'products' ? 'filter-tab--active' : ''}`}
onClick={() => setSearchMode('products')}
>
Parafarmacia ({products.length})
</button>
<div className="results-summary">
{(medicines.length + products.length) > 0 && (
<span>{medicines.length + products.length} resultados encontrados</span>
)}
</div>
{(searchMode === 'all' || searchMode === 'medicines') && (
{medicines.length > 0 && (
<MedicineResults
medicines={medicines}
onSelect={(m) => {
@@ -332,16 +316,17 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
/>
)}
{(searchMode === 'all' || searchMode === 'products') && products.length > 0 && (
{products.length > 0 && (
<div className="products-section">
<h3 className="section-subtitle">Parafarmacia y Bebé</h3>
<h3 className="section-subtitle">Parafarmacia</h3>
<ProductResults
products={products}
onSelect={(p) => {
const productId = p._id || p.id;
if (onNavigateToProduct) {
onNavigateToProduct(p.source, p.id);
onNavigateToProduct(p.source, productId);
} else {
window.location.href = `/product/${p.source}/${p.id}`;
window.location.href = `/product/${p.source}/${productId}`;
}
}}
/>
+13
View File
@@ -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
+47
View File
@@ -0,0 +1,47 @@
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 ./
# Install dependencies (production only)
RUN npm install --omit=dev
# 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"]
+120
View File
@@ -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`
+29
View File
@@ -0,0 +1,29 @@
{
"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",
"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 --passWithNoTests"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"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"
},
"devDependencies": {
"jest": "^29.7.0",
"supertest": "^7.2.2"
}
}
+130
View File
@@ -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
/<div[^>]*class="[^"]*product-card[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/gi,
/<article[^>]*class="[^"]*product[^"]*"[^>]*>([\s\S]*?)<\/article>/gi,
// Generic patterns
/<(?:div|li)[^>]*class="[^"]*(?:product|item)[^"]*"[^>]*>([\s\S]*?)<\/(?:div|li)>/gi,
];
const namePatterns = [
/<h[23][^>]*>([^<]+)<\/h[23]>/i,
/class="[^"]*(?:name|title)[^"]*"[^>]*>([^<]+)</i,
];
const pricePatterns = [
/class="[^"]*price[^"]*"[^>]*>([^<]*\d+[.,]\d+[^<]*)</i,
/(\d+[.,]\d+)\s*€/i,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(html)) !== null) {
const card = match[1];
let name = null;
for (const np of namePatterns) {
const m = np.exec(card);
if (m) { name = m[1].trim(); break; }
}
let price = 0;
for (const pp of pricePatterns) {
const m = pp.exec(card);
if (m) {
price = parseFloat(m[1].replace(/[^0-9.,]/g, '').replace(',', '.'));
break;
}
}
if (name && name.length > 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);
});
+111
View File
@@ -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);
});
+22
View File
@@ -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' },
],
};
+172
View File
@@ -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', 'seed', 'manual'],
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;
@@ -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;
@@ -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;
+208
View File
@@ -0,0 +1,208 @@
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/catalogsearch/result/?q=${encodeURIComponent(q)}`,
selectors: {
product: 'form.product-item',
name: 'form.product-item',
price: 'form.product-item',
link: 'a',
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"]',
'.product-item',
'.product',
'[data-product]'
];
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, 150),
html: el.outerHTML?.substring(0, 500)
}))
};
}
}
// 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, 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');
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);
if (name && priceStr) {
const price = parseFloat(priceStr.replace(/[^0-9.,]/g, '').replace(',', '.')) || 0;
// 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,
source_url: linkEl?.href || '',
image_url: imgEl?.src || null
});
}
}
});
return results;
}, config.selectors, query);
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 };
}
+132
View File
@@ -0,0 +1,132 @@
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';
import scraperRouter from './routes/scraper.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);
app.use('/api', scraperRouter);
// 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;
+1 -1
View File
@@ -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": "",
+62
View File
@@ -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
@@ -84,6 +85,67 @@ services:
depends_on:
- postgres
# --- Parapharmacy API ---
parapharmacy-api:
image: git.hacecalor.net/ichitux/farmafinder-parapharmacy-api:latest
build:
context: ./apps/parapharmacy-api
dockerfile: 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:
# 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
volumes:
backend_data:
postgres_data:
mongodb_data:
n8n_data:
+387
View File
@@ -0,0 +1,387 @@
# 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
| 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 las 6 tiendas
**Para activar:**
1. Ir a http://localhost:5678/workflows
2. Abrir "Parapharmacy Scraper - All Sources"
3. Hacer clic en "Active" toggle
### 2. Parapharmacy Manual Scraper (Webhook)
- **Trigger**: POST a `/webhook/scrape-all`
- **Estado**: Activo por defecto
- **Función**: Scraping bajo demanda de todas las fuentes
**Uso:**
```bash
# 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-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"]}'
```
---
## Fuentes de Scraping
| Fuente | URL | 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 |
---
## 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, ... }
```
---
## 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
+164
View File
@@ -0,0 +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
### 1. Cuenta de Administrador
Al iniciar N8N por primera vez, se crea automáticamente:
| 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 .env
N8N_USER=admin
N8N_PASSWORD=change-me
N8N_EMAIL=admin@farmafinder.com
```
### 3. Importar Workflows
Los workflows se importan automáticamente al iniciar el contenedor. Para importar manualmente:
1. Ir a http://localhost:5678/workflows
2. Hacer clic en "Import from File"
3. Seleccionar el archivo JSON de `n8n/workflows/`
## Fuentes de Scraping
| 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
### 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
# 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-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"]}'
```
### Respuesta del Webhook
```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
- **Dashboard N8N**: http://localhost:5678
- **Historial de ejecuciones**: http://localhost:5678/executions
- **Workflows**: http://localhost:5678/workflows
- **Logs**: `docker logs n8n`
## Troubleshooting
### N8N muestra /setup
```bash
# Verificar variables de entorno
docker-compose exec n8n env | grep N8N
```
### Webhook no funciona
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
```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
+51
View File
@@ -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);
+145
View File
@@ -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][^>]*>([^<]+)<\\/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);
+134
View File
@@ -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: /<div[^>]*class=\"[^\"]*product-card[^\"]*\"[^>]*>([\\s\\S]*?)<\\/div>\\s*<\\/div>/gi,\n nameRegex: /<h[23][^>]*>([^<]+)<\\/h[23]>/i,\n priceRegex: /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i,\n linkRegex: /<a[^>]*href=\"([^\"]+)\"[^>]*>/i,\n imageRegex: /<img[^>]*src=\"([^\"]+)\"[^>]*>/i\n },\n pharmarket: {\n productRegex: /<li[^>]*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: /<img[^>]*class=\"[^\"]*product-image[^\"]*\"[^>]*src=\"([^\"]+)\"/i\n },\n docmorris: {\n productRegex: /<div[^>]*class=\"[^\"]*product-card[^\"]*\"[^>]*>([\\s\\S]*?)<\\/div>/gi,\n nameRegex: /class=\"[^\"]*product-name[^\"]*\"[^>]*>([^<]+)<\\/[^>]+>/i,\n priceRegex: /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i,\n linkRegex: /<a[^>]*href=\"([^\"]+)\"[^>]*>/i,\n imageRegex: /<img[^>]*src=\"([^\"]+)\"[^>]*>/i\n },\n '1001farma': {\n productRegex: /<article[^>]*class=\"[^\"]*product-miniature[^\"]*\"[^>]*>([\\s\\S]*?)<\\/article>/gi,\n nameRegex: /class=\"[^\"]*product-title[^\"]*\"[^>]*>\\s*<a[^>]*>([^<]+)<\\/a>/i,\n priceRegex: /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i,\n linkRegex: /class=\"[^\"]*product-title[^\"]*\"[^>]*>\\s*<a[^>]*href=\"([^\"]+)\"/i,\n imageRegex: /<img[^>]*class=\"[^\"]*img[^\"]*\"[^>]*src=\"([^\"]+)\"/i\n },\n primor: {\n productRegex: /<div[^>]*class=\"[^\"]*product-item[^\"]*\"[^>]*>([\\s\\S]*?)<\\/div>/gi,\n nameRegex: /class=\"[^\"]*product-title[^\"]*\"[^>]*>([^<]+)<\\/[^>]+>/i,\n priceRegex: /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i,\n linkRegex: /<a[^>]*href=\"([^\"]+)\"[^>]*>/i,\n imageRegex: /<img[^>]*src=\"([^\"]+)\"[^>]*>/i\n },\n mifarma: {\n productRegex: /<div[^>]*class=\"[^\"]*product-card[^\"]*\"[^>]*>([\\s\\S]*?)<\\/div>/gi,\n nameRegex: /class=\"[^\"]*product-name[^\"]*\"[^>]*>([^<]+)<\\/[^>]+>/i,\n priceRegex: /class=\"[^\"]*price[^\"]*\"[^>]*>([^<]*\\d+[.,]\\d+[^<]*)<\\/[^>]+>/i,\n linkRegex: /<a[^>]*href=\"([^\"]+)\"[^>]*>/i,\n imageRegex: /<img[^>]*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" }]
}
@@ -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 = /<div class=\"product-card[^\"]*\"[^>]*>([\\s\\S]*?)<\\/div>/gi;\nconst nameRegex = /<h3[^>]*>([^<]+)<\\/h3>/i;\nconst priceRegex = /class=\"price[^\"]*\"[^>]*>([^<]+)<\\/span>/i;\nconst linkRegex = /<a[^>]*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"
}
]
}
+182
View File
@@ -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 = /<div class=\"product-card[^\"]*\"[^>]*>([\\s\\S]*?)<\\/div>/gi;\nconst nameRegex = /<h3[^>]*>([^<]+)<\\/h3>/i;\nconst priceRegex = /class=\"price[^\"]*\"[^>]*>([^<]+)<\\/span>/i;\nconst linkRegex = /<a[^>]*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"
}
]
}
@@ -0,0 +1,99 @@
{
"name": "Parapharmacy Manual Scraper",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "scrape-all",
"responseMode": "lastNode",
"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';\nconst sources = body.sources || ['promofarma'];\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;\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 = /<a[^>]*href=\"(https?:\\/\\/[^\"]+)\"/i;\nconst imageRegex = /<img[^>]*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": {
"jsCode": "const result = $input.first().json;\nreturn [{ json: { success: true, message: 'Scraping completed', result } }];"
},
"id": "format-response",
"name": "Format Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"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": "Format Response", "type": "main", "index": 0 }]] }
},
"active": true,
"settings": { "executionOrder": "v1" },
"tags": [{ "name": "parapharmacy" }, { "name": "scraper" }]
}
+559 -13
View File
@@ -1445,6 +1445,106 @@
"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",
"puppeteer": "^22.0.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0"
},
"devDependencies": {
"jest": "^29.7.0",
"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",
@@ -1475,6 +1575,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 +5246,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 +6131,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 +12782,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 +13331,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 +13444,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 +13759,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 +13770,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 +14465,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 +14664,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 +14935,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 +16059,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 +16875,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 +16905,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 +17157,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 +17172,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 +18726,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 +20483,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 +20536,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 +20895,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 +21114,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 +21914,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 +22654,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 +23527,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 +24187,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 +24785,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 +25038,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 +25628,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",
@@ -25465,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",
@@ -25570,7 +26105,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"
},
@@ -25823,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",
@@ -25970,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",
@@ -26493,7 +27041,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 +27088,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"
+1
View File
@@ -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",