Monorepo con ETL (Python) + web (Next.js).
localizalo.org/
├── apps/
│ ├── etl/ ← Python ETL pipeline
│ └── web/ ← Next.js frontend
├── supabase/ ← Shared infra (DB migrations, config)
├── package.json ← pnpm workspace root
└── pnpm-workspace.yaml
Consolida reportes de personas desaparecidas tras el terremoto de Venezuela 2026 desde múltiples fuentes, deduplica usando emparejamiento fonético, y exporta a PFIF 1.5.
- Fuentes: Cada plataforma expone un endpoint
GET /pfif. El ETL acepta XML (PFIF 1.5) y JSON (array de objetos planos) — la fuente elige cuál le queda más fácil. El ETL negocia víaAccepty detecta el formato porContent-Typede la respuesta. - ETL: Un pipeline en Python corre cada 10 minutos vía GitHub Actions. Por cada fuente:
- Recorre páginas con
updated_after,offsetylimit - Normaliza nombres (Double Metaphone) y ubicaciones (sinónimos curados en
locations.yml) - Genera
person_record_iddeterminístico =sha256(phonetic_hash | location_normalized)[:16] - Deduplica: mismo
person_record_id→ misma persona canónica; nombres fonéticamente similares + misma ubicación → merge como nota histórica - Colisiones de hash (falso positivo): desambigua con sufijo
-<discriminator>y crea persona separada
- Recorre páginas con
- Reconciliación: El job consolidator ejecuta
reconcile_duplicate_persons(RPC en PostgreSQL) que detecta duplicados creados por carreras de ingesta paralela entre fuentes (typos que cambian elphonetic_hash) y fusiona el secundario en el primario. - Almacenamiento: Los datos canónicos viven en Supabase (PostgreSQL), schema
localize. - Exportación: El consolidator genera el PFIF 1.5 unificado con todos los registros deduplicados y sus notas históricas, y lo sube a Supabase Storage como
/pfif/export.xml(latest) y/pfif/exports/export_<run_id>.xml(versionado inmutable).
Implementa un endpoint HTTP público con esta firma:
GET /pfif?updated_after=<ISO 8601>&offset=<int>&limit=<int>
| Parámetro | Tipo | Descripción |
|---|---|---|
updated_after |
string (ISO 8601) | Requerido. Solo devuelve registros actualizados después de esta fecha. Para el backfill inicial el ETL pasa 1970-01-01T00:00:00Z. |
offset |
int | Requerido. Desplazamiento de registros (empieza en 0, incrementa de a 1000). |
limit |
int | Requerido. Máximo de registros por página (el ETL usa 1000). |
El endpoint debe devolver XML o JSON. El ETL detecta el formato automáticamente por el Content-Type de la respuesta.
Content-Type: application/xml
<?xml version="1.0" encoding="UTF-8"?>
<pfif:pfif xmlns:pfif="http://zesty.ca/pfif/1.5">
<pfif:person>
<pfif:person_record_id>midominio.com/a1b2c3</pfif:person_record_id>
<pfif:full_name>María Fernández</pfif:full_name>
<pfif:given_name>María</pfif:given_name>
<pfif:family_name>Fernández</pfif:family_name>
<pfif:age>30</pfif:age>
<pfif:last_known_location>Catia La Mar</pfif:last_known_location>
<pfif:description>Cabello negro, ojos marrones</pfif:description>
<pfif:photo_url>https://tudominio.com/fotos/123.jpg</pfif:photo_url>
<pfif:status>missing</pfif:status>
<pfif:source_date>2026-06-25T13:58:00Z</pfif:source_date>
<pfif:author_name>Familiar</pfif:author_name>
</pfif:person>
</pfif:pfif>Content-Type: application/json
Devuelves un array de objetos planos. Las llaves son las mismas que en XML, sin prefijo pfif:.
[
{
"person_record_id": "midominio.com/a1b2c3",
"full_name": "María Fernández",
"given_name": "María",
"family_name": "Fernández",
"age": 30,
"last_known_location": "Catia La Mar",
"description": "Cabello negro, ojos marrones",
"photo_url": "https://tudominio.com/fotos/123.jpg",
"status": "missing",
"source_date": "2026-06-25T13:58:00Z",
"author_name": "Familiar"
}
]En ambos formatos, los campos contacto, localizado_por, localizado_contacto, localizado_relacion y localizado_nota son opcionales y se aceptan como campos extra.
En XML, los campos extra pueden ir como <pfif:contacto>, <pfif:localizado_por>, etc., o dentro de <pfif:other> con formato Clave: valor | Clave: valor.
El ETL itera offset=0, 1000, 2000... hasta recibir una respuesta con menos de limit registros. La respuesta vacía ([]) también termina la iteración. Entre páginas se aplica un rate_limit_ms configurable por fuente (default 100ms).
| Valor | Significado |
|---|---|
missing |
Desaparecido, sin noticias |
found |
Localizado sano y salvo |
deceased |
Fallecido |
injured |
Herido / en hospital |
unknown |
Estado desconocido |
- CORS: El endpoint debe ser accesible desde GitHub Actions (cualquier User-Agent).
- Tiempo de respuesta: Preferiblemente <5s para una página de 100 registros.
- Ordenamiento: Los registros deben devolverse ordenados por
updatedAtascendente, para queupdated_afterfuncione correctamente.
Haz un PR a este repositorio agregando tu fuente en sources.yml:
sources:
- id: mi-plataforma
name: Mi Plataforma
namespace: miplataforma.org
base_url: https://api.miplataforma.org
rate_limit_ms: 100 # opcional, default 100| Campo | Requerido | Descripción |
|---|---|---|
id |
Sí | Identificador único (guiones, sin espacios). |
name |
Sí | Nombre legible para humanos. |
namespace |
Sí | Dominio o prefijo para identificar tus registros en notas históricas. |
base_url |
Sí | URL raíz de tu API. El ETL llama a {base_url}/pfif?updated_after=&offset=&limit=. |
rate_limit_ms |
No | Milisegundos de espera entre requests paginados (default: 100). |
La matriz de GitHub Actions se genera automáticamente desde sources.yml — no necesitas editar el workflow. Solo agrega tu entrada y haz el PR.
Eso es todo. No necesitas escribir código Python ni entender el ETL.
Cada ciclo el ETL genera los datos consolidados con todos los registros deduplicados y sus notas históricas en dos formatos:
| Formato | URL |
|---|---|
| PFIF 1.5 XML | https://insoelfklgcmshjpuhrb.supabase.co/storage/v1/object/public/pfif/export.xml |
| JSON | https://insoelfklgcmshjpuhrb.supabase.co/storage/v1/object/public/pfif/export.json |
Ambos contienen la misma data. Las organizaciones humanitarias (Cruz Roja, Google Person Finder) pueden consumir el XML; el JSON está disponible para quien prefiera integrarlo sin parsear XML.
Cuando dos registros de diferentes fuentes se consideran la misma persona vía Phonetic Match (cercanía fonética >= PHONETIC_THRESHOLD (0.8), similitud de nombre >= NAME_SIMILARITY_THRESHOLD (0.9) y misma ubicación normalizada), el ETL no crea una segunda persona canónica. En su lugar, el registro de la fuente secundaria se almacena como una nota histórica adjunta a la persona canónica existente, siguiendo el formato de <pfif:note>.
Cada nota se genera con el siguiente formato (producido por _build_note_text en etl/main.py):
Registro también reportado por <namespace>. | ID original: <external_id>.
Campos adicionales si existen:
| Foto: <photo_url>
| Nota: <localizado_nota>
Ejemplo:
Registro también reportado por midominio.org. | ID original: midominio.org/a1b2c3. | Foto: https://tudominio.com/fotos/123.jpg | Nota: Reportado como localizado en hospital de Valencia
- El campo
statusde la persona canónica sí se actualiza cuando una fuente secundaria reporta un estatus de mayor prioridad (deceased > found > injured > missing > unknown), tanto enatomic_upsert_person(mismoperson_record_id) como enatomic_merge_note(Phonetic Match). El orden de prioridad está definido porstatus_to_priorityen la migración 012. - Si el estatus reportado tiene igual o menor prioridad que el actual, el cambio queda únicamente en la nota histórica y no se propaga al registro canónico.
- Las notas deben tratarse como metadatos de procedencia / auditoría; el consumo de la nota es necesario para conocer estados alternativos reportados por otras fuentes.
- First-writer-wins: Los campos descriptivos (
full_name,given_name,family_name,age,description,photo_url,author_name) siguen semántica first-writer-wins víaCOALESCE(persons.field, EXCLUDED.field)enatomic_upsert_person. Una vez que un campo recibe un valor no-nulo de la primera fuente, fuentes posteriores no lo sobreescriben (migración 018). La excepción esstatusque escala por prioridad.
person_record_iddeterminístico:sha256(phonetic_hash | location_normalized)[:16]. Misma persona + misma ubicación → mismo ID siempre, entre todas las fuentes y ejecuciones.note_record_iddeterminístico:sha256(person_record_id | source_id | external_id)[:16]. Notas idempotentes víaON CONFLICT DO NOTHING.source_recordsidempotentes:ON CONFLICT (source_id, external_id) DO UPDATE.- Watermark desde datos, no wall-clock: El watermark
updated_afterse calcula comomax(source_date) - 1sde los registros fetcheados, no del reloj del servidor. Esto evita pérdida de datos por TOCTOU si el fetch y el cómputo del watermark no son atómicos. - Watermark no avanza si hay errores: Si algún record falla durante el procesamiento, el watermark no se actualiza. El siguiente ciclo re-fetchea la misma ventana (idempotente).
- Fetch parcial no avanza watermark: Si el fetch falla a mitad de la paginación, el ETL retorna sin avanzar el watermark. El siguiente ciclo reintenta desde el último watermark exitoso.
Cuando dos fuentes A y B se ejecutan concurrentemente (cada 10 min en jobs paralelos de GitHub Actions), pueden procesar a la misma persona simultáneamente. Si un typo en una fuente cambia el phonetic_hash, cada fuente genera un person_record_id distinto para la misma persona → dos registros canónicos duplicados.
El consolidator ejecuta después de todos los jobs de fuente y llama a reconcile_duplicate_persons repetidamente hasta que no queden pares por fusionar. El algoritmo:
- Encuentra pares con misma ubicación normalizada y nombres trigram-similares (
similarity > 0.5) - Reasigna
source_recordsynotesdel secundario al primario - Promueve el status de mayor prioridad al primario
- Inserta nota de auditoría documentando la fusión
- Elimina el registro secundario
La función procesa pares disjuntos por lote (sin transitividad) para evitar fusiones incorrectas en cadena.
- Sentry: El ETL reporta excepciones a Sentry vía
SENTRY_DSN. Errores de fetch y fallos de procesamiento por registro se capturan sin detener el pipeline. - Errores de fetch: Si el fetch falla (red, timeout), el ETL registra el error en Sentry y retorna sin avanzar el watermark. El error no causa fallo del job de CI (soft fail) — el siguiente ciclo (10 min después) reintenta automáticamente.
- Errores de registro: Si un registro individual falla (ej. nombre inválido), se omite y se incrementa el contador de errores. Si la tasa de error supera el 10% de los registros fetcheados, el job termina con
exit(1). - Resumen JSON: Cada ejecución emite un resumen estructurado con conteos de creados, actualizados, mergeados, notas agregadas y errores.
- Detección de colisión de
person_record_id:is_full_matchusa nombre, edad y contacto para distinguir colisiones de hash. Como la tablapersonsno tiene columnacontacto(está ensource_records), el chequeo de contacto no actúa como discriminador. Solo nombre y edad pueden diferenciar una colisión real. En la práctica esto es extremadamente improbable porque requiere mismophonetic_hash+ misma ubicación + mismo nombre + edad similar para dos personas distintas. - Registros sin ubicación: Cuando un registro no tiene
last_known_location, el phonetic match se ejecuta sin filtro de ubicación, lo que puede producir matches contra cualquier persona con nombre fonéticamente similar. Se recomienda que las fuentes siempre provean ubicación.
Las migraciones están en supabase/migrations/. Aplicarlas en orden numérico.
for f in supabase/migrations/*.sql; do
psql "$DATABASE_URL" -f "$f"
donesupabase db push- ETL: Python 3.12 + httpx + phonetics + python-Levenshtein
- Base de datos: Supabase (PostgreSQL 15), schema
localize. Migraciones ensupabase/migrations/. Se usará la extensiónpg_trgmpara búsqueda difusa al optimizar el emparejamiento fonético. - Orquestación: GitHub Actions (cron
*/10 * * * *, matrix por fuente + consolidator) - Formato de exportación: PFIF 1.5
- Schema
localizecon RLS habilitado. - Las tablas
persons,notesysource_recordstienen políticas SELECT público (lectura sin autenticación). - Todos los RPC de escritura/mezcla (
atomic_upsert_person,atomic_merge_note,reconcile_duplicate_persons) son SECURITY DEFINER. Su ejecución está restringida a los rolesauthenticatedyservice_role; los rolesanonypublictienen el execute revocado (migraciones 010, 014). - El ETL se conecta usando la clave
service_role(víaSUPABASE_SERVICE_KEY) para invocar estos RPC.