Buscador de precios de medicamentos en Argentina
Sistema open source que procesa datos oficiales de SIAFAR/COFA/PAMI y genera un comparador de precios con actualización automática dos veces al día.
- ✨ Demo en Vivo
- 📊 Dataset actual
- 🎯 Funcionamiento General
- 🧭 Principios del Proyecto
- 👤 Flujo del Usuario
- 🧠 Algoritmo de Búsqueda y Filtrado
- 🔄 Actualización Automática de Datos
- 📦 Estructura de Datos JSON
- ⚡ Optimizaciones Implementadas
- ⏱️ Tiempos de Respuesta
- 🏗️ Arquitectura del Sistema
- 📁 Estructura del Repositorio
- 🧰 Stack Tecnológico
- 🧠 Decisiones Técnicas
- 💻 Ejecución Local
- 🐍 Scripts Python
- 📊 Métricas y Rendimiento
- 🔍 SEO y Metadatos
- 🔒 Seguridad y Privacidad
- 🔌 API No Oficial
- 👥 Guía de Contribución
- 📊 Diagramas de Flujo Detallados
- 🧩 Referencia de Componentes Frontend
- 🎨 Guía de Estilos CSS
- 🔧 Documentación de Workflows
- ❓ Preguntas Frecuentes (FAQ)
⚠️ Limitaciones conocidas- 🗺️ Roadmap
- 📄 Licencia
- 🙏 Fuente de Datos
| Entorno | URL | Propósito |
|---|---|---|
| GitHub Pages (dominio propio, DNS en Cloudflare) | remedi.ar | Producción — alojado en GitHub |
| GitHub Pages (dominio por defecto) | psbella.github.io/remediar | Mirror/respaldo |
| Cloudflare Workers | remediar.pablo-s-bella.workers.dev | Mirror/respaldo |
Headers de seguridad:
remedi.arywww.remedi.arestán proxied (nube naranja) en Cloudflare, con GitHub Pages como origen. La CSP,X-Frame-Optionsy el resto de los headers de seguridad se aplican vía Cloudflare Response Header Transform Rules (dashboard), no desde el archivo_headersdel repo — ese archivo solo lo procesa el mirror de Workers. Ver_headerspara el detalle de los valores replicados.
| Métrica | Valor |
|---|---|
| Registros | ~12.100 |
| Drogas únicas | ~1.740 |
| Tamaño JSON | ~2.5 MB |
| Tamaño gzip | ~520 KB |
| Con cobertura PAMI | ~5.900 (49%) |
| Entradas en blacklist | 569 |
| Cobertura parser de presentaciones | ~99.5% |
| Actualizaciones | 2 veces/día (lunes a viernes) |
| Tests de sanidad | 13 checks automáticos post-ETL |
El sistema se compone de tres capas principales:
- GitHub Actions ejecuta un workflow automático dos veces al día (lunes a viernes)
- Se descarga el PDF oficial desde SIAFAR / COFA
- Python extrae y normaliza los registros mediante un pipeline de 8+ capas
- Se cruzan los datos con el vademécum de PAMI para enriquecer cobertura
- Se genera
medicamentos.json
- El proyecto es 100% estático
- GitHub Pages sirve el contenido como origen (dominio propio
remedi.arvía DNS de Cloudflare, y el dominio por defectopsbella.github.io/remediar) - Cloudflare actúa como proxy delante de
remedi.ar/www.remedi.ar: CDN, TLS, y una Transform Rule que inyecta los headers de seguridad (GitHub Pages no soporta headers custom) - Un mirror adicional corre en Cloudflare Workers (
remediar.pablo-s-bella.workers.dev), sirviendo los mismos assets estáticos de forma independiente - No existe backend persistente ni base de datos
index.htmlcarga la aplicación- Los datos se descargan una sola vez y se indexan en memoria
- La búsqueda ocurre completamente del lado cliente
- El estado UI es reactivo mediante
store.js(patrón pub/sub)
- Acceso libre a información de medicamentos
- Sin publicidad
- Sin tracking
- Performance primero
- Mobile first
- Open source
- Infraestructura simple y transparente
- Datos públicos y auditables
sequenceDiagram
autonumber
participant U as 👤 Usuario
participant B as 🌐 Navegador
participant CDN as ⚡ Cloudflare CDN
participant CACHE as 💾 sessionStorage
participant JSON as 📦 medicamentos.json
participant STORE as 🧠 store.js
participant UI as 🖥️ uiRenderer.js
U->>B: Ingresa a remedi.ar
B->>CDN: GET /index.html
CDN-->>B: HTML + CSS + JS
B->>B: Render inicial (skeleton)
B->>STORE: Inicializar estado
alt Caché válida (< 2 horas)
B->>CACHE: Leer medicamentos.json
CACHE-->>B: Datos cacheados
else Caché vacía o vencida
B->>CDN: GET /data/medicamentos.json
CDN-->>B: JSON comprimido (~520KB gzip)
B->>CACHE: Guardar datos + timestamp
end
B->>STORE: Indexar medicamentos
STORE->>UI: Render primeros resultados
U->>B: Escribe "ibuprofeno"
B->>B: Debounce 250ms
B->>STORE: Ejecutar búsqueda
STORE->>STORE: Filtrar + ordenar
STORE->>UI: Actualizar resultados + dropdowns
U->>B: Activa filtro PAMI
STORE->>STORE: Recalcular filtros
STORE->>UI: Render reactivo
U->>B: Click en medicamento
UI-->>U: Mostrar detalles + badge PAMI
searchEngine.js construye un índice invertido de prefijos sobre droga, marca y laboratorio. Por cada token de 2 o más caracteres se generan todos sus prefijos, mapeados a conjuntos de índices del array de medicamentos.
for (const palabra of txt.split(/\s+/)) {
for (let k = 2; k <= palabra.length; k++) {
const pref = palabra.slice(0, k);
if (!indice[pref]) indice[pref] = new Set();
indice[pref].add(i);
}
}La búsqueda realiza una intersección AND entre todos los términos ingresados — "ibuprofeno bago" devuelve solo registros que contengan ambos tokens.
Los resultados se ordenan por tres criterios en cascada:
- Relevancia textual — score basado en el campo donde ocurre el match:
| Match | Score |
|---|---|
| Droga exacta | +100 |
| Droga empieza con el término | +80 |
| Droga contiene el término | +50 |
| Marca exacta | +40 |
| Marca empieza con el término | +25 |
| Marca contiene el término | +15 |
| Laboratorio contiene el término | +5 |
- vigencia_score — productos con precios confiables primero
- precio — ascendente como desempate final
Los registros con vigencia_score < 50 siempre van al fondo, independientemente del score de relevancia.
flowchart TD
A[⏰ Cron GitHub Actions]
B[📥 Descargar PDF SIAFAR]
C[📄 Extraer registros por página]
D[🧹 Limpiar y normalizar]
N1[🔧 Pipeline de normalización 8+ capas]
BL[🛡️ Aplicar blacklist]
E[🔍 Detectar outliers]
F[💾 Generar medicamentos.json]
R[📋 Generar outlier_report.json]
CSV[🔬 Generar presentaciones_debug.csv]
T[🧪 Tests de sanidad pytest]
H[📤 Commit automático]
I[🚀 GitHub Pages actualizado, servido vía proxy de Cloudflare]
A --> B
B --> C
C --> D
D --> N1
N1 --> BL
BL --> E
E --> F
E --> R
E --> CSV
F --> T
T --> H
R --> H
CSV --> H
H --> I
El parser aplica correcciones en cascada para resolver los problemas estructurales del PDF de SIAFAR:
| Capa | Función | Descripción |
|---|---|---|
| 0 | reparar_droga_faltante() |
Cuando el PDF omite la línea del principio activo, todos los campos se desplazan. Separa droga+marca fusionadas usando un diccionario de prefijos truncados |
| 1 | Detección en parse | Detecta registros con 4 campos en lugar de 5 durante la extracción del PDF |
| 2 | rescatar_laboratorios() |
Recupera laboratorio="Desconocido" buscando el lab como sufijo en presentacion |
| 3 | reparar_denver() |
Denver Farma usa droga+lab como nombre comercial; separa marca y presentacion fusionadas (variantes DENCR., DF) |
| 4 | reparar_marca_desplazada() |
Cuando marca empieza con dígito y presentacion está vacía, invierte el desplazamiento |
| 5 | extraer_presentacion_de_marca() |
Extrae la presentacion fusionada en el campo marca. Antes del regex de corte: (1) separa laboratorios pegados sin espacio (_build_re_lab_pegado(), dinámico por dataset); (2) separa formas farmacéuticas pegadas (_RE_FORMA_PEGADA); (3) elimina duplicados mayúscula+minúscula (_RE_TOKEN_DUPLICADO) |
| 5b | reparar_presentacion_desplazada() |
Separa presentacion+lab fusionados en el campo lab (3 sub-patrones: 2A, 2B, 2C) |
| 5c | limpiar_dosis_residual_en_marca() |
Limpia la dosis numérica que queda pegada al nombre del laboratorio en marca |
| 6 | crosswalk_pami() |
Cruza contra el vademécum de PAMI (descargado en cada corrida desde la API pública de datos abiertos, ver más abajo): recupera droga vacía, corrige laboratorio, normaliza presentacion, agrega pami_cobertura |
| 7 | aplicar_droga_fixes() |
Aplica correcciones manuales desde data/droga_fixes.json |
Cada una de estas funciones vive en su propio módulo dentro de
scripts/etl/(ver Paquetescripts/etl/);pdf_to_json.pysolo orquesta el orden de ejecución.
name: 🔃 Actualizar precios
on:
schedule:
- cron: '30 13,21 * * 1-5'
workflow_dispatch:
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- run: pip install -r requirements.txt
- run: python scripts/pdf_to_json.py
- name: Verificar sanidad del output
run: pytest tests/ -v
- name: Snapshot semanal (solo viernes)
if: github.event_name == 'schedule'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "$(date +%u)" = "5" ]; then
python scripts/snapshot_semanal.py
fi
- name: Commit y push
run: |
git config user.name "github-actions[bot]"
git config user.email "actions@github.com"
git add data/medicamentos.json
git add data/outlier_report.json
git add data/presentaciones_debug.csv
git commit -m "Actualizar precios $(date +'%Y-%m-%d')" || echo "No changes"
git pull --rebase origin main
git push origin main{
"droga": "ibuprofeno",
"marca": "IBUPIRAC",
"presentacion": "400 mg comp.x 20",
"laboratorio": "Pfizer",
"precio": 9800.50,
"pami_cobertura": 55,
"pres_forma": "COMPRIMIDOS",
"pres_dosis": "400",
"pres_unidad": "MG",
"pres_cantidad": "20",
"vigencia_score": 100,
"flags": [],
"precio_outlier_tipo": null,
"outlier_razones": []
}| Campo | Tipo | Descripción |
|---|---|---|
droga |
string | Principio activo (nombre genérico) |
marca |
string | Nombre comercial |
presentacion |
string | Dosis, forma farmacéutica y cantidad |
laboratorio |
string | Laboratorio fabricante |
precio |
number | PVP en ARS (fuente: SIAFAR) |
pami_cobertura |
number|null | Porcentaje de cobertura PAMI (ej: 55). Null si no está en el vademécum |
pres_forma |
string|null | Forma farmacéutica parseada (ej: "COMPRIMIDOS RECUBIERTOS", "JARABE") |
pres_dosis |
string|null | Dosis numérica (ej: "400", "500") |
pres_unidad |
string|null | Unidad de la dosis (ej: "MG", "ML", "UI") |
pres_cantidad |
string|null | Cantidad de unidades (ej: "20", "100 ml") |
vigencia_score |
number | Score de confiabilidad del precio (0-100). < 50 = outlier |
flags |
array | Etiquetas de anomalía (precio_bajo, precio_sospechoso, precio_obsoleto) |
precio_outlier_tipo |
string|null | Categoría del outlier detectado |
outlier_razones |
array | Descripción de por qué es outlier |
| Archivo | Descripción |
|---|---|
data/pami.xlsx |
Vademécum PAMI, descargado automáticamente en cada corrida desde la API de datos abiertos de PAMI (no se versiona en git). Usado para: (1) cobertura por marca+presentacion, (2) recuperar droga faltante, (3) corregir laboratorio, (4) normalizar el campo presentacion |
data/droga_fixes.json |
Correcciones manuales marca→droga para casos no resolubles con regex |
data/blacklist.json |
569 registros excluidos manualmente. Las claves usan el formato droga|marca|presentacion|laboratorio en minúsculas |
data/outlier_report.json |
Reporte detallado de outliers de la última corrida |
data/presentaciones_debug.csv |
Auditoría del parser: presentacion_original vs. campos parseados (forma, dosis, unidad, cantidad) |
.debug/medicamentos.pretty.json |
Versión formateada con indent=2 del dataset, solo para debug local — no se publica en el sitio ni se versiona en git |
droga_fixes.json es editable manualmente — no hace falta tocar el código para cubrir nuevas marcas sin principio activo en el PDF.
Dos formatos soportados:
// Solo droga (la marca ya está bien parseada)
"FORXIGA": "dapagliflozina"
// Droga + corrección de marca (droga y marca estaban fusionadas)
"DICLOFENAC POTÁSICO, PARACETAM KINALGIN P": {
"droga": "diclofenac potásico, paracetamol",
"marca": "KINALGIN P"
}La clave es siempre el valor del campo marca o droga en mayúsculas tal como aparece en el JSON. El workflow lo aplica automáticamente en cada corrida.
El JSON se carga una sola vez y se indexa en memoria con un índice invertido de prefijos. Sin requests adicionales para cada búsqueda.
store.js controla búsqueda, filtros, ordenamiento y render reactivo con un patrón pub/sub manual — sin dependencias externas.
La búsqueda espera 250ms luego de la última tecla para no saturar el índice.
Los datos se almacenan en sessionStorage con TTL de 2 horas. La clave remedios_data_v2 permite invalidar el caché en deploys sin romper sesiones activas.
Al buscar un medicamento, los filtros de presentación y laboratorio se actualizan para mostrar solo las opciones disponibles en los resultados actuales.
CSS optimizado para móviles, tablets y desktop sin frameworks externos.
300 resultados por render para no bloquear el hilo principal. Los outliers (vigencia_score < 50) siempre aparecen al final, independientemente del orden seleccionado.
Seleccionar laboratorio o presentación desde el desplegable muestra resultados aunque el campo de búsqueda esté vacío.
Service Worker con estrategia network-first para datos y cache-first para assets estáticos. Íconos en SVG + PNG (192×192 y 512×512) para instalación en todos los dispositivos.
CSP via header HTTP (no meta tag) con hash SHA256 del script inline de GA. style-src sin unsafe-inline (estilos migrados a CSS externo). X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy y Access-Control-Allow-Origin: * para el JSON público. remedi.ar/www.remedi.ar) estos headers los aplica una Cloudflare Response Header Transform Rule, no el archivo _headers del repo — ver por qué.
Cada tarjeta tiene un botón "Compartir" que abre el menú nativo en mobile o copia el link al portapapeles en desktop. Cada medicamento tiene una URL única con hash (remedi.ar/#droga--marca--laboratorio--presentacion). Al abrir un link compartido, el medicamento aparece destacado arriba con glow teal y productos similares debajo. Los eventos de compartir se registran en GA4.
13 tests pytest corren después de cada actualización del ETL y antes del commit. Si alguno falla, el workflow se detiene y el sitio sigue sirviendo los datos anteriores. 12 validan umbrales de calidad de negocio (cantidad de registros, % de campos vacíos, rango de precios); el 13° (test_schema.py) valida el contrato estructural completo del JSON contra un JSON Schema versionado — si el ETL cambia la forma del output, este test avisa hasta que el schema se actualice a propósito.
============================= test session starts ==============================
platform linux -- Python 3.11.15, pytest-9.1.1, pluggy-1.6.0
collected 13 items
tests/test_etl_sanidad.py::test_cantidad_minima PASSED [ 7%]
tests/test_etl_sanidad.py::test_cantidad_maxima PASSED [ 15%]
tests/test_etl_sanidad.py::test_campos_presentes PASSED [ 23%]
tests/test_etl_sanidad.py::test_precios_positivos PASSED [ 30%]
tests/test_etl_sanidad.py::test_precio_mediana_razonable PASSED [ 38%]
tests/test_etl_sanidad.py::test_drogas_vacias PASSED [ 46%]
tests/test_etl_sanidad.py::test_laboratorios_desconocidos PASSED [ 53%]
tests/test_etl_sanidad.py::test_marcas_vacias PASSED [ 61%]
tests/test_etl_sanidad.py::test_vigencia_score_rango PASSED [ 69%]
tests/test_etl_sanidad.py::test_pami_cobertura_rango PASSED [ 76%]
tests/test_etl_sanidad.py::test_estructura_raiz PASSED [ 84%]
tests/test_etl_sanidad.py::test_fecha_presente PASSED [ 92%]
tests/test_schema.py::test_schema_valido PASSED [100%]
13 passed in 1.79s
| Métrica | Valor |
|---|---|
| FCP | 0.8 - 1.2s |
| LCP | 1.5 - 2.0s |
| TTI | 1.8 - 2.5s |
| Búsqueda en índice | 25 - 100ms |
| TTFB | 50 - 150ms |
flowchart LR
subgraph ONE["🌐 FUENTE EXTERNA"]
A[("SIAFAR / COFA\nPDF Oficial")]
B["📄 Publicación diaria"]
end
subgraph TWO["⚙️ AUTOMATIZACIÓN"]
C["⏰ Cron GitHub Actions\n10:30 y 18:30 AR"]
D["🔄 Workflow manual"]
end
subgraph THREE["🐍 ETL Python"]
E["pdf_to_json.py\n(orquestador)"]
E2["scripts/etl/\n8+ capas normalización\nen módulos independientes"]
G["📊 medicamentos.json"]
end
subgraph REF["📋 REFERENCIA"]
H["Vademécum PAMI\n(API, descarga en runtime)"]
I["droga_fixes.json"]
J["blacklist.json (569)"]
end
subgraph FIVE["🌐 FRONTEND"]
K["index.html"]
L["store.js (pub/sub)"]
M["searchEngine.js\n(índice invertido)"]
N["uiRenderer.js"]
end
subgraph SEVEN["☁️ HOSTING"]
Q["GitHub Pages\n(origen real)"]
R["Cloudflare\n(proxy + headers de seguridad)"]
end
A --> B
B --> C
D --> C
C --> E
E --> E2
H --> E2
I --> E2
J --> E2
E2 --> G
G --> K
K --> L
L --> M
M --> N
G --> Q
Q --> R
remediar/
├── index.html
├── style.css
├── manifest.json
├── requirements.txt
├── robots.txt
├── sitemap.xml
├── sw.js
├── privacidad.html
├── terminos.html
├── README.md
├── _headers
├── .nojekyll
├── .gitignore
│
├── img/
│ ├── favicon.svg
│ ├── logo_banner.svg
│ ├── icon-192.png
│ ├── icon-512.png
│ └── og-image.png
│
├── js/
│ ├── main.js
│ ├── store.js
│ ├── dataLoader.js
│ ├── filters.js
│ ├── searchEngine.js
│ ├── uiRenderer.js
│ └── utils.js
│
├── data/
│ ├── medicamentos.json
│ ├── outlier_report.json
│ ├── presentaciones_debug.csv
│ ├── blacklist.json
│ ├── droga_fixes.json
│ └── pami.xlsx # descargado en runtime, no versionado
│
├── scripts/
│ ├── pdf_to_json.py # orquestador: encadena las capas de etl/
│ ├── etl/
│ │ ├── config.py # constantes y paths compartidos
│ │ ├── parser.py # descarga del PDF y parseo a lista de medicamentos
│ │ ├── reparaciones.py # capas de reparación de campos mal parseados
│ │ ├── droga_fixes.py # fixes manuales + reparación de droga faltante
│ │ ├── presentacion.py # extracción/parseo/debug de presentaciones
│ │ ├── pami.py # crosswalk contra el vademécum PAMI
│ │ ├── blacklist.py # carga y filtrado de la lista negra
│ │ ├── outliers.py # detección de outliers y cálculo de vigencia
│ │ ├── enriquecimiento.py # enriquecimiento de campos de presentación/dosis
│ │ └── utils.py # helpers de parseo/limpieza básicos
│ └── snapshot_semanal.py
│
├── tests/
│ ├── conftest.py
│ ├── test_etl_sanidad.py
│ ├── test_schema.py
│ └── medicamentos.schema.json
│
└── .github/workflows/
├── update_prices.yml
├── maintenance-on.yml
└── maintenance-off.yml
| Capa | Tecnología |
|---|---|
| Frontend | HTML5 + CSS3 + Vanilla JS (ES Modules) |
| Estado UI | Patrón pub/sub manual (store.js) |
| Backend ETL | Python 3.11 |
| Parsing PDF | PyMuPDF |
| Crosswalk PAMI | pandas + openpyxl |
| Datos | JSON estático |
| CI/CD | GitHub Actions |
| Testing | pytest |
| Hosting | GitHub Pages (origen) + Cloudflare (proxy/DNS) + Cloudflare Workers (mirror) |
| SEO | JSON-LD + Open Graph + Twitter Cards |
| Caché | sessionStorage (TTL 2h) + Service Worker |
| Seguridad | CSP via header HTTP + SHA256 hash |
| PWA | Service Worker + Web App Manifest |
- Cero dependencias en runtime
- Mejor tiempo de carga
- Sin actualizaciones de seguridad por dependencias transitivas
- Mantenimiento sencillo a largo plazo
- Hosting estático con costo prácticamente cero
- CDN extremadamente eficiente
- Menor complejidad operacional
- El dataset (~12.000 registros) cabe perfectamente en memoria
El PDF de SIAFAR no tiene un esquema tabular estricto. Distintos laboratorios omiten campos, fusionan droga+marca sin separador, o desplazan la presentación al campo laboratorio. Las capas se aplican en cascada de menor a mayor complejidad, garantizando que cada corrección no interfiera con las anteriores.
- GitHub Pages es gratuito, confiable, y ya aloja el repo — cero infraestructura extra que mantener
- Importante: GitHub Pages no soporta un archivo
_headerspara headers HTTP personalizados (esa convención es de Cloudflare Pages/Netlify, no de GitHub Pages). El archivo_headersdel repo documenta los valores deseados, pero quien los aplica de verdad enremedi.ar/www.remedi.ares una Cloudflare Response Header Transform Rule, configurada en el dashboard (no en el repo) — ver la nota en la sección de Arquitectura - Cloudflare como proxy (nube naranja) suma CDN global, HTTPS gestionado, y la posibilidad de inyectar esos headers sin tocar el origen
- El mirror en Cloudflare Workers (
remediar.pablo-s-bella.workers.dev) sirve como respaldo independiente: al ser Workers Static Assets, sí procesa el_headersdel repo nativamente, así que ese archivo no queda del todo huérfano
git clone https://github.com/psbella/remediar.git
cd remediar
python -m http.server 8000npx http-server -p 8000 --cors -c-1pip install -r requirements.txt
python scripts/pdf_to_json.pypytest tests/ -vFROM nginx:alpine
COPY . /usr/share/nginx/htmldocker build -t remediar .
docker run -p 8080:80 remediar| Script | Función |
|---|---|
scripts/pdf_to_json.py |
Orquestador: encadena las capas de scripts/etl/ en orden y persiste medicamentos.json, outlier_report.json y presentaciones_debug.csv. Ya no contiene la lógica de las capas — solo el flujo. |
scripts/snapshot_semanal.py |
Genera un CSV con los precios confiables (vigencia_score ≥ 50) de la semana y lo sube como asset a la release mensual de GitHub (historial-YYYY-MM). Se ejecuta automáticamente cada viernes. |
tests/test_etl_sanidad.py |
12 tests de sanidad sobre el output del ETL: cantidad de registros, campos obligatorios, rangos de precios, calidad de datos y estructura del JSON |
| Módulo | Función |
|---|---|
etl/config.py |
Constantes y paths compartidos por todos los módulos del ETL |
etl/parser.py |
Descarga del PDF de SIAFAR, parseo a lista de medicamentos y deduplicación de registros exactos |
etl/reparaciones.py |
Capas de reparación de campos mal parseados desde el PDF (laboratorios desplazados, fusiones Denver Farma, marca desplazada, presentación desplazada) |
etl/droga_fixes.py |
Fixes manuales de droga y reparación de registros con droga faltante |
etl/presentacion.py |
Extracción de presentación fusionada en marca, limpieza de dosis residual y generación del debug de presentaciones |
etl/pami.py |
Crosswalk contra el vademécum PAMI vigente para recuperar droga y corregir laboratorio |
etl/blacklist.py |
Carga y filtrado de la lista negra de medicamentos |
etl/outliers.py |
Detección de precios outlier/obsoletos y cálculo de vigencia |
etl/enriquecimiento.py |
Enriquecimiento de registros con campos de presentación y dosis |
etl/utils.py |
Helpers de parseo y limpieza básicos |
| Métrica | Valor |
|---|---|
| Lighthouse Performance | 94-96 |
| Accessibility | 98 |
| Best Practices | 100 |
| SEO | 100 |
| CLS | 0.02 |
| FID | 12ms |
- JSON-LD (
WebSite+SearchAction) - Open Graph
- Twitter Cards
- Sitemap.xml
- robots.txt con
crawl-delaypara bots agresivos
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "remedi.ar",
"potentialAction": {
"@type": "SearchAction",
"target": "https://remedi.ar/?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}- No se recopilan datos personales
- No se utilizan cookies de tracking
- No existe backend persistente
- Todo el frontend es auditable públicamente
- Content Security Policy via header HTTP con hash SHA256 del único script inline (Google Analytics):
script-src 'self' 'sha256-...' https://www.googletagmanager.com - CORS habilitado en
/data/medicamentos.jsonpara consumo externo (Access-Control-Allow-Origin: *) robots.txtbloquea explícitamente GPTBot y ClaudeBot- Google Analytics configurado en modo anónimo — ver política de privacidad
El JSON de medicamentos es público y accesible libremente bajo licencia MIT.
| Método | URL |
|---|---|
| GET | https://remedi.ar/data/medicamentos.json |
| GET | https://raw.githubusercontent.com/psbella/remediar/main/data/medicamentos.json |
const response = await fetch('https://remedi.ar/data/medicamentos.json');
const { medicamentos } = await response.json();
// Filtrar por droga con cobertura PAMI
const conPami = medicamentos.filter(m => m.pami_cobertura > 0);
// Calcular copago PAMI
const copago = m => Math.round(m.precio * (1 - m.pami_cobertura / 100));
// Filtrar por forma farmacéutica
const comprimidos = medicamentos.filter(m => m.pres_forma?.includes('COMPRIMIDOS'));import pandas as pd
df = pd.read_json("https://remedi.ar/data/medicamentos.json")
meds = pd.json_normalize(df['medicamentos'])
# Filtrar solo los que tienen cobertura PAMI
con_pami = meds[meds['pami_cobertura'].notna()]- ¿Un precio, laboratorio o cobertura PAMI están mal? Abrí un issue con el template "🩺 Precio o dato incorrecto" — es el tipo de reporte más útil para este proyecto.
- ¿Algo no funciona en la web? Usá el template "🐛 Bug del sitio".
- ¿Una idea o mejora? Template "💡 Idea o mejora" — revisá primero el Roadmap por si ya está anotado.
git clone https://github.com/psbella/remediar.git
git checkout -b feature/nueva-funcion
# hacer cambios
git commit -m "feat: descripción del cambio"
git push origin feature/nueva-funcion
# abrir Pull Request (se completa solo con el template del repo)Antes de abrir el PR: si tocaste el ETL, corré pytest tests/ y confirmá que pasen los 13 tests (12 de sanidad + el de schema); si tocaste JS/CSS/HTML, probá el cambio en el navegador, no alcanza con leer el diff.
| Tipo | Uso |
|---|---|
feat |
Nueva funcionalidad |
fix |
Corrección de bug |
docs |
Documentación |
perf |
Performance |
chore |
Mantenimiento / limpieza |
security |
Cambios de seguridad |
main no tiene branch protection activa. Es una decisión consciente: el repo tiene un solo colaborador con acceso de escritura, y GitHub no permite eximir al bot de github-actions de las reglas de protección en cuentas personales — activarla hubiera roto el workflow automático que pushea 2 veces al día. Si en algún momento se suma otro colaborador con acceso de escritura, esto se reevalúa.
Si modificás index.html, style.css o cualquier archivo en js/, acordate de bumpear CACHE_NAME en sw.js (ej. remediar-v5 → remediar-v6). Esos archivos están precacheados por el Service Worker (CACHE_STATIC), así que sin el bump los usuarios que ya visitaron el sitio van a seguir viendo la versión vieja indefinidamente, sin ningún error visible — simplemente no se actualiza nada hasta que el navegador decida revalidar el cache por su cuenta.
flowchart TD
A[PDF SIAFAR]
B[Descarga + extracción por página]
C0[Capa 0: reparar_droga_faltante]
C1[Capa 1: desplazamiento en parse]
C2[Capa 2: rescatar_laboratorios]
C3[Capa 3: reparar_denver]
C4[Capa 4: reparar_marca_desplazada]
C5[Capa 5: extraer_presentacion_de_marca]
C5B[Capa 5b: reparar_presentacion_desplazada]
C5C[Capa 5c: limpiar_dosis_residual_en_marca]
C6[Capa 6: crosswalk_pami]
C7[Capa 7: aplicar_droga_fixes]
BL[Blacklist 569 entradas]
OUT[Detección de outliers IQR]
PRES[Parser de presentaciones]
T[🧪 pytest 13 tests]
JSON[medicamentos.json]
DEBUG[presentaciones_debug.csv]
REPORT[outlier_report.json]
A --> B
B --> C0
C0 --> C1
C1 --> C2
C2 --> C3
C3 --> C4
C4 --> C5
C5 --> C5B
C5B --> C5C
C5C --> C6
C6 --> C7
C7 --> BL
BL --> OUT
OUT --> PRES
PRES --> T
T --> JSON
PRES --> DEBUG
OUT --> REPORT
flowchart TD
IN["marca='CARBOPLATINO MICROSULES150 mg iny.f.a.x 1'\npresentacion=''"]
subgraph PRE["Pre-limpieza (en orden)"]
A["1. _RE_TOKEN_DUPLICADO\nElimina token mayúscula duplicado\nGELgel → gel\nBOLSAbolsa → bolsa"]
B["2. _RE_FORMA_PEGADA\nInserta espacio antes de forma pegada\nBENZOCAINA GELgel → BENZOCAINA gel"]
C["3. _build_re_lab_pegado (dinámico)\nInserta espacio entre lab conocido y dosis pegada\nMICROSULES150 → MICROSULES 150"]
end
D{"¿_RE_EXTRAER_PRES\nhace match?"}
E["marca = grupo 1\npresentacion = grupo 2"]
F["Registro sin cambios\n(caso no resuelto)"]
IN --> A --> B --> C --> D
D -- Sí --> E
D -- No --> F
style PRE fill:#f0f8f0,stroke:#aaa
El regex _build_re_lab_pegado se construye dinámicamente en cada corrida a partir de los laboratorios ya presentes en el dataset. Esto evita mantener una lista hardcodeada que se desactualiza.
flowchart TD
P["presentacion: '400 mg comp.rec.x 20'"]
P1["Normalizar prefijos: Ad. Ped. Rtd."]
P2["Extraer dosis + unidad"]
P3["Buscar forma farmacéutica en FORMAS_MAP (60+ entradas)"]
P4{"¿Forma encontrada?"}
P5["Fallback: scan en cualquier posición"]
P6["Extraer cantidad"]
P7["pres_forma / pres_dosis / pres_unidad / pres_cantidad"]
P --> P1 --> P2 --> P3 --> P4
P4 -- Sí --> P6
P4 -- No --> P5 --> P6
P6 --> P7
| Campo generado | Ejemplo |
|---|---|
pres_forma |
"COMPRIMIDOS RECUBIERTOS" |
pres_dosis |
"400" |
pres_unidad |
"MG" |
pres_cantidad |
"20" |
Cobertura actual: ~99.5%. El archivo data/presentaciones_debug.csv permite auditar los casos no resueltos después de cada corrida.
sequenceDiagram
participant U as 👤 Usuario
participant M as main.js
participant S as store.js
participant SE as searchEngine.js
participant F as filters.js
participant R as uiRenderer.js
U->>M: input "ibuprofeno bago"
M->>M: debounce 250ms
M->>S: setFiltroTexto("ibuprofeno bago")
S->>SE: buscar("ibuprofeno bago")
Note over SE: Normaliza → ["ibuprofeno","bago"]
Note over SE: Intersección AND de índices de prefijos
Note over SE: Ordena por relevancia + vigencia + precio
SE-->>S: resultados ordenados
S->>F: aplicarFiltros(resultados, presentacion, laboratorio, soloPami)
F-->>S: resultados filtrados
S->>S: notificar()
S->>R: suscribirse callback
R->>R: cargarOpcionesFiltros(resultados)
Note over R: Dropdowns muestran solo opciones del resultado actual
R->>R: mostrarResultados(resultados)
Note over R: renderPresentacion() y renderPrecios() — funciones nombradas
R-->>U: Tarjetas con chips de presentación + badge PAMI
flowchart TD
subgraph ACCIONES["Acciones"]
A1[setFiltroTexto]
A2[setFiltroPresentacion]
A3[setFiltroLaboratorio]
A4[setFiltroOrden]
A5[setSoloPami]
A6[limpiarFiltros]
end
subgraph RECALC["recalcularResultados()"]
R1{"¿hay texto\no filtro activo?"}
R2["buscar(texto)\n→ índice invertido"]
R3["todos los medicamentos"]
R4["aplicarFiltros()"]
R5{"orden ≠\n'relevancia'?"}
R6["ordenar()"]
R7["state.resultados = …"]
end
subgraph UI["UI (suscriptores)"]
U1["cargarOpcionesFiltros()"]
U2["mostrarResultados()"]
U3["mostrarMensajeInicial()"]
end
ACCIONES --> RECALC
R1 -- No --> U3
R1 -- hayTexto --> R2 --> R4
R1 -- soloFiltros --> R3 --> R4
R4 --> R5
R5 -- Sí --> R6 --> R7
R5 -- No --> R7
R7 --> notificar
notificar --> U1
notificar --> U2
flowchart LR
subgraph SIAFAR["📄 SIAFAR / PDF"]
S1[droga]
S2[marca]
S3[presentacion]
S4[laboratorio]
S5[precio]
end
subgraph PAMI["📋 Vademécum PAMI (API)"]
P1[pami_cobertura]
P2["droga (recuperación)"]
P3["laboratorio (corrección)"]
P4["presentacion (normalización)"]
end
subgraph PARSER["🔧 _parsear_presentacion()"]
PR1[pres_forma]
PR2[pres_dosis]
PR3[pres_unidad]
PR4[pres_cantidad]
end
subgraph OUTLIER["📊 Detección outliers (IQR)"]
O1[vigencia_score]
O2[flags]
O3[precio_outlier_tipo]
O4[outlier_razones]
end
subgraph JSON["📦 medicamentos.json"]
J[Registro final]
end
S1 & S2 & S3 & S4 & S5 --> J
P1 & P2 & P3 & P4 --> J
PR1 & PR2 & PR3 & PR4 --> J
O1 & O2 & O3 & O4 --> J
- Estado global reactivo con patrón pub/sub (
suscribirse/notificar) - Filtros: texto, laboratorio, presentacion, orden, soloPami
- Sin texto ni filtros activos →
resultados = [](muestra mensaje inicial) - Con filtros activos y sin texto → parte del dataset completo y aplica filtros
- Ordenamiento con conciencia de vigencia:
vigencia_score < 50siempre al fondo
- Render de tarjetas con principio activo en mayúsculas
- Chips de presentación: usa
pres_forma/pres_dosis/pres_unidad/pres_cantidaddel JSON cuando están disponibles; cae aparsearPresentacion()(JS) como fallback - En modo PAMI activo, muestra el copago estimado como precio principal y el PVP como referencia secundaria
- Chip PAMI con formato "Cobertura PAMI 55% · $4.500"
- Skeleton loaders + mensajes de error/vacío
- Scroll-to-top automático al superar 300px de scroll
- Renderizado modular:
renderPresentacion(med)yrenderPrecios(med, soloPami)son funciones nombradas — sin IIFEs anónimas en template literals hashMedicamento(med): genera hash único por medicamento (droga--marca--laboratorio--presentacion) para deep linkscompartirMedicamento(med):navigator.shareen mobile, fallback a clipboard en desktop, con evento GA4- Tarjeta destacada con glow teal permanente y badge "Producto compartido" al abrir un link compartido
- Separador "Productos similares" entre la tarjeta destacada y los resultados por droga
normalizar(): lowercase + quita tildes para búsquedaformatearPrecio(): formato ARS contoLocaleStringescapeHtml(): escape de&,<,>,",'para prevenir XSSnormalizarLaboratorio(): resuelve laboratorios truncados por el PDFparsearPresentacion(): parser JS de fallback (60+ formas enFORMAS_MAP)extraerFiltros(): construye sets de presentaciones y laboratorios válidos para dropdowns
- Caché con
sessionStorage(claveremedios_data_v2, TTL 2 horas) - Fetch con
priority: 'high' - Fallback silencioso si
sessionStorageestá bloqueado
- Índice invertido de prefijos sobre
droga,marcaylaboratorio - Búsqueda AND multi-término normalizada (sin tildes, lowercase)
- Ranking por relevancia textual (droga > marca > lab),
vigencia_scorey precio - Registros con
vigencia_score < 50degradados al fondo
aplicarFiltros(): filtrado por presentación, laboratorio y PAMIordenar(): ordenamiento con conciencia de vigencia (vigencia_score < 50siempre al fondo)esValorCorrupto(): detección de laboratorios con valores numéricos o de presentación en el campo lab
:root {
--teal: #008B8B;
--teal-dark: #005f5f;
--teal-darker: #003f3f;
--teal-light: #e6f2f2;
--teal-accent: #0e7490;
--text-1: #111111;
--text-4: #777777;
--r-sm: 8px; --r-md: 12px; --r-lg: 16px;
}Un único breakpoint mobile-first en 600px — no hay un nivel intermedio de tablet separado, el layout de mobile se extiende hasta desktop.
| Breakpoint | Tamaño |
|---|---|
| Mobile | ≤ 600px |
| Desktop | > 600px |
| Workflow | Trigger | Función |
|---|---|---|
update_prices.yml |
Cron 30 13,21 * * 1-5 + manual |
ETL principal: descarga PDF, genera JSON, corre tests, hace commit |
maintenance-on.yml |
Manual | Reemplaza index.html con página de mantenimiento |
maintenance-off.yml |
Manual | Restaura index.html desde backup |
codeql.yml |
Push/PR a main + cron semanal (lunes 06:00 UTC) |
Análisis estático de seguridad (CodeQL) sobre JS y Python |
dependabot.yml (config, no workflow) |
Semanal | Propone actualizaciones de requirements.txt y de las actions usadas en los workflows |
| Parámetro | Valor |
|---|---|
| Schedule | 10:30 y 18:30 AR (lunes a viernes) |
| Runtime | Ubuntu latest |
| Python | 3.11 |
| Caché de dependencias | cache: 'pip' en setup-python@v5 |
| Dependencias | Ver requirements.txt |
| Trigger manual | Sí (workflow_dispatch) |
| Pull antes de push | Sí (git pull --rebase) |
| Tests | pytest antes de cada commit |
| Snapshot semanal | Viernes — CSV subido a GitHub Releases (historial-YYYY-MM) |
Del PDF oficial publicado por SIAFAR / COFA dos veces al día, de lunes a viernes.
Un score de 0 a 100 que indica la confiabilidad del precio. Se calcula usando estadística IQR (rango intercuartílico) por droga + detección de inconsistencias de escala. Un score < 50 indica que el precio es probable outlier (obsoleto, cero, o estadísticamente anómalo respecto a la mediana de la droga).
Muestra la cobertura y el copago estimado en un solo chip: "Cobertura PAMI 55% · $4.500".
El copago se calcula como precio × (1 - cobertura / 100).
PVP SIAFAR: $10.000
Cobertura PAMI: 55%
Copago estimado: $10.000 × (1 - 0.55) = $4.500
Es una aproximación — el copago real puede variar porque el porcentaje de cobertura es del vademécum PAMI y el precio base es el PVP actualizado de SIAFAR.
Dos veces al día, de lunes a viernes (10:30 y 18:30 hora Argentina).
No.
No. Usamos Google Analytics en modo anónimo para entender el uso del sitio.
Sí, bajo licencia MIT. El endpoint está habilitado con Access-Control-Allow-Origin: *.
Cada medicamento tiene una URL única con hash: remedi.ar/#droga--marca--laboratorio--presentacion. Al abrirlo, la app muestra ese medicamento destacado arriba y productos similares debajo. El botón "Compartir" en cada tarjeta abre el menú nativo en mobile o copia el link en desktop.
Sí, desde el primer viernes de implementación. Cada viernes se genera un snapshot CSV con los precios confiables de la semana y se sube como asset a la release mensual de GitHub (historial-YYYY-MM). Los snapshots están disponibles públicamente en la sección Releases del repositorio.
| Limitación | Descripción |
|---|---|
| ~9 registros sin presentación | El PDF de SIAFAR no incluye la presentación para estas marcas (KETOSTERIL, FRENALER D, DEXALERGIN, VIXALERG, KINALGIN P, ASFARADIL, FEMIDEN, SIGNORINA, VAXNEUVANCE). No son errores del parser — el dato simplemente no está en la fuente. |
pami_cobertura es aproximado |
El porcentaje proviene del vademécum PAMI (que se actualiza con menor frecuencia) aplicado sobre el PVP actual de SIAFAR. El copago real puede diferir. |
| Precios de SIAFAR en ARS | Con la inflación argentina, los precios pueden quedar desactualizados entre corridas. El vigencia_score ayuda a identificar los registros más sospechosos. |
| PDF de SIAFAR sin esquema fijo | Distintos laboratorios aplican su propia semántica al PDF. El pipeline de 8+ capas resuelve los patrones conocidos; pueden aparecer casos nuevos en futuras corridas. |
| SSL de SIAFAR | El servidor de SIAFAR tiene un certificado con chain incompleta. La verificación SSL usa certifi como CA bundle. |
Corrección de verificación SSL en descarga de SIAFAR✅Tests automatizados del ETL✅Refactor IIFEs en uiRenderer.js✅Compartir medicamentos con deep link✅Snapshots semanales de precios en GitHub Releases✅- Filtro por forma farmacéutica en la UI (usando
pres_forma, ya disponible en el JSON) - Historial de precios (visualización en frontend)
- Integración con API REST de ANMAT (trámite en curso — respuesta esperada 10/07/2026)
- IOMA como segunda fuente de crosswalk
- API REST pública documentada
- Dashboard estadístico de variación de precios
- Instagram con contenido generado automáticamente
- Evolución histórica de precios
- App móvil nativa
- Integración con farmacias en tiempo real
MIT License. Uso libre para proyectos personales y comerciales con atribución. Texto completo en LICENSE.
Datos proporcionados por SIAFAR / COFA. Cobertura PAMI desde el vademécum oficial del PAMI.
| Recurso | URL |
|---|---|
| Producción | https://remedi.ar |
| GitHub Pages (dominio por defecto) | https://psbella.github.io/remediar/ |
| Mirror (Cloudflare Workers) | https://remediar.pablo-s-bella.workers.dev/ |
| Repositorio | https://github.com/psbella/remediar |
| Actions / CI | https://github.com/psbella/remediar/actions |
| medicamentos.json | https://remedi.ar/data/medicamentos.json |
| Sitemap | https://remedi.ar/sitemap.xml |
| Política de privacidad | https://remedi.ar/privacidad.html |
| Términos y condiciones | https://remedi.ar/terminos.html |
Hecho con ❤️ para que los medicamentos sean más accesibles en Argentina.