Feat/parapharmacy api #38
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}`, {
|
||||
// Search both CIMA and Parapharmacy
|
||||
const [cimaRes, paraRes] = await Promise.allSettled([
|
||||
fetch(`/api/products/search?q=${encodeURIComponent(q)}`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
setProductResults(Array.isArray(data) ? data : []);
|
||||
}),
|
||||
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,7 +329,7 @@ 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>}
|
||||
@@ -301,13 +338,15 @@ function PharmacyProductLink() {
|
||||
<div className="medicine-search-results">
|
||||
{productResults.slice(0, 10).map((product) => (
|
||||
<div
|
||||
key={product._id || product.id}
|
||||
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,11 +356,12 @@ 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"
|
||||
|
||||
@@ -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="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>
|
||||
)}
|
||||
<div className="product-pharmacy-status">
|
||||
{pharmacy.price && (
|
||||
<span className="product-pharmacy-price">
|
||||
{parseFloat(pharmacy.price).toFixed(2)} €
|
||||
</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'}
|
||||
{locationError && (
|
||||
<span className="location-error">
|
||||
{locationError}
|
||||
<button className="retry-location-btn" onClick={handleSortByDistance}>
|
||||
Reintentar
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<PharmacyMap pharmacies={displayedPharmacies} />
|
||||
|
||||
<PharmacyList
|
||||
pharmacies={displayedPharmacies}
|
||||
loading={loadingPharmacies}
|
||||
userPosition={sortByDistance ? userPosition : null}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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`
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' },
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
Generated
+559
-13
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user