diff --git a/.env.example b/.env.example index afe688e..cd84a0c 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,6 @@ VERIFIED_TOKEN_TTL_SECONDS=600 LOCKOUT_ATTEMPTS=5 LOCKOUT_SECONDS=600 MIN_SOLVE_MS=1200 + +INGEST_SCHEDULER_ENABLED=true +INGEST_SCHEDULER_INTERVAL_MS=86400000 diff --git a/.gitignore b/.gitignore index 91b916c..15f23d0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage apps/api/dist packages/shared/dist packages/widget/dist/*.map +demo/index.html diff --git a/Makefile b/Makefile index b451224..fab7e3e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ SHELL ?= /bin/zsh DEMO_PORT ?= 3000 -.PHONY: help install build lint test test-all migrate ingest ingest-docker sitekey dev setup setup-docker up down logs clean demo +.PHONY: help install build lint test test-integration test-all migrate ingest ingest-docker sitekey dev setup setup-docker up down logs clean demo demo-docker demo-prepare help: @echo "Targets disponibles:" @@ -9,6 +9,7 @@ help: @echo " make build - Build de todos los paquetes" @echo " make lint - Ejecuta checks de TypeScript" @echo " make test - Ejecuta tests" + @echo " make test-integration - Ejecuta test live contra Missing Children" @echo " make test-all - Ejecuta lint + tests (docker)" @echo " make migrate - Ejecuta migraciones de DB" @echo " make ingest - Carga casos desde Missing Children (local)" @@ -20,8 +21,9 @@ help: @echo " make up - Levanta docker compose (postgres + migrator + api)" @echo " make down - Baja docker compose" @echo " make logs - Logs de docker compose" - @echo " make clean - Borra node_modules y artefactos de build" - @echo " make demo - Prepara demo completa y sirve en http://localhost:$(DEMO_PORT)" + @echo " make clean - Borra node_modules, artefactos de build y datos locales de docker/db" + @echo " make demo - Prepara demo completa con datos reales y sirve en http://localhost:$(DEMO_PORT)" + @echo " make demo-docker - Prepara demo completa con datos reales y la sirve desde un contenedor docker temporal en http://localhost:$(DEMO_PORT)" install: @npm install @@ -35,9 +37,13 @@ lint: test: @npm run --workspaces test +test-integration: + @npm run -w @missing-captcha/api test:integration + test-all: @docker compose run --rm api npm run --workspaces lint @docker compose run --rm api npm run --workspaces test + @docker compose run --rm api npm run -w @missing-captcha/api test:integration migrate: @npm run -w @missing-captcha/api migrate @@ -55,7 +61,9 @@ setup: install up @npm run -w @missing-captcha/api seed:sitekey "$(or $(DOMAINS),localhost)" @npm run -w @missing-captcha/widget build -setup-docker: up ingest-docker +setup-docker: up + @docker compose exec -T api npm run -w @missing-captcha/api migrate + @${MAKE} ingest-docker @docker compose exec -T api npm run -w @missing-captcha/api seed:sitekey "$(or $(DOMAINS),localhost)" @docker compose exec -T api npm run -w @missing-captcha/widget build @@ -77,14 +85,21 @@ logs: clean: @rm -rf node_modules apps/api/dist packages/shared/dist @rm -f packages/widget/dist/widget.js packages/widget/dist/widget.css - -demo: up - @PUBLIC_KEY=$$(docker compose exec -T api npm run -w @missing-captcha/api seed:sitekey "localhost" | grep -Eo 'rc_pk_[a-f0-9]+' | tail -n 1); \ - if [ -z "$$PUBLIC_KEY" ]; then echo "No se pudo generar publicKey"; exit 1; fi; \ - npm run -w @missing-captcha/widget build; \ - mkdir -p demo; \ - cp packages/widget/dist/widget.js demo/widget.js; \ - cp packages/widget/dist/widget.css demo/widget.css; \ + @docker compose down -v --remove-orphans + +demo-prepare: up ingest-docker + @PUBLIC_KEY=$$(docker compose exec -T api npm run -w @missing-captcha/api seed:sitekey "localhost" | grep -Eo 'rc_pk_[a-f0-9]+' | tail -n 1) && \ + if [ -z "$$PUBLIC_KEY" ]; then echo "No se pudo generar publicKey"; exit 1; fi && \ + docker compose exec -T api npm run -w @missing-captcha/widget build && \ + mkdir -p demo && \ + docker compose exec -T api cat /app/packages/widget/dist/widget.js > demo/widget.js && \ + docker compose exec -T api cat /app/packages/widget/dist/widget.css > demo/widget.css && \ sed "s/__PUBLIC_KEY__/$$PUBLIC_KEY/g" demo/index.template.html > demo/index.html + +demo: demo-prepare @echo "Demo lista en http://localhost:$(DEMO_PORT)" @python3 -m http.server $(DEMO_PORT) -d demo + +demo-docker: demo-prepare + @echo "Demo lista en http://localhost:$(DEMO_PORT)" + @docker run --rm -p $(DEMO_PORT):80 -v "$$PWD/demo:/usr/share/nginx/html:ro" nginx:alpine diff --git a/README.md b/README.md index 7ab5b31..34cf40c 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,26 @@ Monorepo con: ```sh cp .env.example .env make demo +# o +make demo-docker ``` Esto: - levanta `postgres + migrator + api` +- refresca casos reales desde Missing Children en la DB local - crea un `siteKey` para `localhost` - compila `widget.js` y `widget.css` - genera `demo/index.html` - sirve la demo en `http://localhost:3000` -Nota: `make demo` ya no corre ingest automáticamente. +Diferencias: + +- `make demo` sirve la carpeta `demo/` localmente con `python3 -m http.server` +- `make demo-docker` sirve la carpeta `demo/` desde un contenedor temporal `nginx:alpine` +- ambos generan el widget dentro del contenedor `api`, así no requieren `npm install` local para el build del demo + +Nota: `make demo` y `make demo-docker` ejecutan ingest para refrescar los casos reales antes de servir la demo. ## Setup local @@ -51,17 +60,36 @@ o make ingest-docker ``` +- Test live contra el sitio externo: + +```sh +make test-integration +``` - El ingest consume datos públicos desde: - - `https://www.missingchildren.org.ar/listado.php?categoria=perdidos` - - `https://www.missingchildren.org.ar/listado.php?categoria=buscan` - - `https://www.missingchildren.org.ar/listado.php?categoria=mayores` -- El script recorre fichas individuales, toma la primera foto y extrae el bloque de contacto previo a `SI TIENE ALGUNA INFORMACIÓN CONTÁCTENOS:`. -- Durante la ejecución muestra progreso por categoría y aplica timeout por request para evitar bloqueos largos por red. + - `https://www.missingchildren.org.ar/pages/galeria_ajax.php?limite=&offset=&situacionBusqueda=perdidos` + - `https://www.missingchildren.org.ar/pages/detalles.php?id=` +- El ingest usa un parser HTML real (`cheerio`) para leer la galería y las fichas de detalle. +- El sync es incremental por `source_external_id`: inserta nuevos casos, actualiza existentes, elimina casos marcados como encontrados y también elimina casos ausentes cuando su `updated_at` supera el umbral configurado. +- `contact_info` se llena con líneas fallback normalizadas (por ejemplo teléfono + email) y el CTA de WhatsApp se guarda en `report_url`. +- Durante la ejecución muestra progreso por página y aplica timeout por request para evitar bloqueos largos por red. - Timeout configurable con `INGEST_REQUEST_TIMEOUT_MS` (default: `12000` ms). +- El API ya incluye un scheduler interno diario. Al arrancar, corre ingest solo si la DB todavía no tiene casos. Variables: + - `INGEST_SCHEDULER_ENABLED` (default: `true`) + - `INGEST_SCHEDULER_INTERVAL_MS` (default: `86400000`) + - `INGEST_DELETE_MISSING_AFTER_DAYS` (default: `30`) + +## Tests + +```sh +make test +make test-integration +make test-all +``` -Roadmap: -- Agregar un cron job (por ejemplo diario) para refrescar la base automáticamente sin depender de ejecución manual. +- `make test` corre la suite normal. +- `make test-integration` corre un test real contra Missing Children (galería + detalle). +- `make test-all` corre lint, tests normales y el test live dentro de docker. ## Embed snippet @@ -104,6 +132,7 @@ Roadmap: ## Notas - Provider actual es demo (`apps/api/src/jobs/ingest_missing_children.ts`) con datos públicos/no sensibles. +- La selección de casos en runtime ya no rota por categorías; el origen activo se simplificó a `perdidos`. - Para producción: reemplazar ingest demo por feed/API acordado y agregar A11y avanzada + métricas. ## Troubleshooting rápido diff --git a/apps/api/migrations/003_cases_external_fields.sql b/apps/api/migrations/003_cases_external_fields.sql new file mode 100644 index 0000000..ff90c4d --- /dev/null +++ b/apps/api/migrations/003_cases_external_fields.sql @@ -0,0 +1,8 @@ +ALTER TABLE cases + ADD COLUMN IF NOT EXISTS source_external_id INTEGER, + ADD COLUMN IF NOT EXISTS gender TEXT, + ADD COLUMN IF NOT EXISTS birth_date DATE; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_cases_org_source_external_id + ON cases(organization_id, source_external_id) + WHERE source_external_id IS NOT NULL; \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index fe580c2..f9e36af 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,6 +11,7 @@ "seed:sitekey": "tsx src/scripts/create_site_key.ts", "ingest:missing": "tsx src/jobs/ingest_missing_children.ts", "test": "node --test --import tsx test/*.test.ts", + "test:integration": "node --test --import tsx test/*.integration.ts", "lint": "tsc -p tsconfig.json --noEmit" }, "dependencies": { @@ -18,6 +19,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^5.6.0", "@missing-captcha/shared": "0.1.0", + "cheerio": "^1.2.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.45.1", "fastify": "^5.2.1", diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 88b99fd..f98333a 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -20,8 +20,11 @@ export const siteKeys = pgTable("site_keys", { export const cases = pgTable("cases", { id: uuid("id").primaryKey().defaultRandom(), organizationId: uuid("organization_id"), + sourceExternalId: integer("source_external_id"), name: text("name").notNull(), age: integer("age"), + gender: text("gender"), + birthDate: date("birth_date", { mode: "date" }), photoUrl: text("photo_url"), lastSeenDate: date("last_seen_date", { mode: "date" }), lastSeenLocationCity: text("last_seen_location_city"), diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index fd5bc74..8ee37e4 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -20,4 +20,6 @@ export const env = { LOCKOUT_ATTEMPTS: Number(process.env.LOCKOUT_ATTEMPTS ?? 5), LOCKOUT_SECONDS: Number(process.env.LOCKOUT_SECONDS ?? 600), MIN_SOLVE_MS: Number(process.env.MIN_SOLVE_MS ?? 1200), + INGEST_SCHEDULER_ENABLED: (process.env.INGEST_SCHEDULER_ENABLED ?? "true").toLowerCase() !== "false", + INGEST_SCHEDULER_INTERVAL_MS: Number(process.env.INGEST_SCHEDULER_INTERVAL_MS ?? 86400000), }; diff --git a/apps/api/src/jobs/ingest_missing_children.ts b/apps/api/src/jobs/ingest_missing_children.ts index d6757c5..8812d74 100644 --- a/apps/api/src/jobs/ingest_missing_children.ts +++ b/apps/api/src/jobs/ingest_missing_children.ts @@ -1,218 +1,33 @@ +import { pathToFileURL } from "node:url"; + import { pool } from "../db/pool.js"; import { - clearCasesForWebsiteOrLegacy, + deleteCasesByIds, findOrganizationByWebsite, + findManagedCasesForOrganization, insertCase, insertOrganization, + updateCase, withIngestTransaction, } from "../repositories/ingest-repository.js"; - -type SourceCategory = "perdidos" | "buscan" | "mayores"; - -type ScrapedCase = { - name: string; - age: number | null; - photoUrl: string | null; - lastSeenDate: string | null; - city: string | null; - contactInfo: string[]; - moreInfoUrl: string; - reportUrl: string | null; - sourceCategory: SourceCategory; -}; +import { + parseDetailCase, + parseGalleryEntries, + type ScrapedCase, +} from "../lib/missing-children-parser.js"; +import { planCaseSync } from "../lib/missing-children-sync.js"; const BASE_URL = "https://www.missingchildren.org.ar"; -const SOURCE_CATEGORIES: SourceCategory[] = ["perdidos", "buscan", "mayores"]; +const GALLERY_URL = `${BASE_URL}/pages/galeria_ajax.php`; +const SOURCE_CATEGORY = "perdidos"; +const PAGE_SIZE = Number.parseInt(process.env.INGEST_GALLERY_PAGE_SIZE ?? "100", 10); const REQUEST_TIMEOUT_MS = Number.parseInt(process.env.INGEST_REQUEST_TIMEOUT_MS ?? "12000", 10); +const DELETE_MISSING_AFTER_DAYS = Number.parseInt(process.env.INGEST_DELETE_MISSING_AFTER_DAYS ?? "30", 10); -function absoluteUrl(pathOrUrl: string): string { - if (/^https?:\/\//i.test(pathOrUrl)) { - return pathOrUrl; - } - return new URL(pathOrUrl, `${BASE_URL}/`).toString(); -} - -function decodeHtmlEntities(input: string): string { - const named: Record = { - nbsp: " ", - amp: "&", - lt: "<", - gt: ">", - quot: '"', - apos: "'", - aacute: "á", - eacute: "é", - iacute: "í", - oacute: "ó", - uacute: "ú", - Aacute: "Á", - Eacute: "É", - Iacute: "Í", - Oacute: "Ó", - Uacute: "Ú", - ntilde: "ñ", - Ntilde: "Ñ", - uuml: "ü", - Uuml: "Ü", - }; - - return input.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (full, code: string) => { - if (code.startsWith("#x") || code.startsWith("#X")) { - const value = Number.parseInt(code.slice(2), 16); - return Number.isFinite(value) ? String.fromCodePoint(value) : full; - } - if (code.startsWith("#")) { - const value = Number.parseInt(code.slice(1), 10); - return Number.isFinite(value) ? String.fromCodePoint(value) : full; - } - return named[code] ?? full; - }); -} - -function stripHtmlToLines(fragment: string): string[] { - const withNewLines = fragment - .replace(/]*>[\s\S]*?<\/script>/gi, " ") - .replace(/]*>[\s\S]*?<\/style>/gi, " ") - .replace(/<\/?(br|p|div|li|tr|h\d)\b[^>]*>/gi, "\n") - .replace(/<\/?(td|th)\b[^>]*>/gi, " "); - - const noTags = withNewLines.replace(/<[^>]+>/g, " "); - const decoded = decodeHtmlEntities(noTags) - .replace(/\r/g, "\n") - .replace(/[\t\u00A0]+/g, " "); - - return decoded - .split("\n") - .map((line) => line.replace(/\s+/g, " ").trim()) - .filter(Boolean); -} - -function normalizeContactLines(lines: string[]): string[] { - const compact = lines - .map((line) => line.replace(/\s+/g, " ").trim()) - .filter(Boolean) - .filter((line) => !/^Imprimir poster$/i.test(line)); - - const merged: string[] = []; - for (const line of compact) { - const prev = merged[merged.length - 1]; - if (prev && /:$/.test(prev) && !/:$/.test(line)) { - merged[merged.length - 1] = `${prev} ${line}`; - continue; - } - merged.push(line); - } - - return merged; -} - -function extractPrimaryInfoTable(detailHtml: string): string { - const tableMatch = detailHtml.match(/]*id=["']table(?:7|11)["'][^>]*>([\s\S]*?)<\/table>/i); - if (tableMatch?.[1]) { - return tableMatch[1]; - } - - const markerIndex = detailHtml.search(/SI\s+TIENE\s+ALGUNA\s+INFORMACI/i); - if (markerIndex > 0) { - return detailHtml.slice(0, markerIndex); - } - return detailHtml; -} - -function extractName(lines: string[]): string { - for (const line of lines) { - if (/^FALTA\s+DESDE:/i.test(line)) { - continue; - } - if (line.length > 2) { - return line; - } - } - return "Sin nombre"; -} - -function extractAge(lines: string[]): number | null { - const preferred = lines.find((line) => /^Edad actual:/i.test(line)) ?? lines.find((line) => /^Edad en la foto:/i.test(line)); - if (!preferred) { - return null; - } - const match = preferred.match(/(\d{1,3})/); - return match ? Number.parseInt(match[1], 10) : null; -} - -function parseSpanishDateToIso(raw: string): string | null { - const text = raw.toLowerCase(); - const match = text.match(/(\d{1,2})\s+de\s+([a-záéíóú]+)\s+de\s+(\d{4})/i); - if (!match) { - return null; - } - const monthByName: Record = { - enero: 1, - febrero: 2, - marzo: 3, - abril: 4, - mayo: 5, - junio: 6, - julio: 7, - agosto: 8, - septiembre: 9, - setiembre: 9, - octubre: 10, - noviembre: 11, - diciembre: 12, - }; - - const day = Number.parseInt(match[1], 10); - const month = monthByName[match[2].normalize("NFD").replace(/[\u0300-\u036f]/g, "")]; - const year = Number.parseInt(match[3], 10); - - if (!month || Number.isNaN(day) || Number.isNaN(year)) { - return null; - } - - return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; -} - -function extractMissingSinceDate(lines: string[]): string | null { - const idx = lines.findIndex((line) => /^FALTA\s+DESDE:/i.test(line)); - if (idx < 0) { - return null; - } - const candidate = lines[idx + 1]; - if (!candidate) { - return null; - } - return parseSpanishDateToIso(candidate); -} - -function extractResidenceCity(lines: string[]): string | null { - const idx = lines.findIndex((line) => /^LUGAR\s+DE\s+RESIDENCIA:/i.test(line)); - if (idx < 0) { - return null; - } - const candidate = lines[idx + 1]; - return candidate ?? null; -} - -function parseListingEntries(listHtml: string): Array<{ name: string; detailUrl: string }> { - const cardRegex = /]*class=["']perdidos["'][^>]*>\s*]*>\s*\s*<\/tr>\s*\s*]*class=["']perdidos["'][^>]*>([\s\S]*?)<\/td>/gi; - const entries: Array<{ name: string; detailUrl: string }> = []; - - for (const match of listHtml.matchAll(cardRegex)) { - const detailUrl = absoluteUrl(match[1]); - const name = normalizeContactLines(stripHtmlToLines(match[2]))[0] ?? "Sin nombre"; - entries.push({ name, detailUrl }); - } - - const unique = new Map(); - for (const entry of entries) { - if (!unique.has(entry.detailUrl)) { - unique.set(entry.detailUrl, entry); - } - } - - return [...unique.values()]; -} +type ScrapeResult = { + cases: ScrapedCase[]; + foundExternalIds: Set; +}; async function fetchText(url: string): Promise { const controller = new AbortController(); @@ -240,47 +55,43 @@ async function fetchText(url: string): Promise { return await response.text(); } -function parseDetailCase(detailHtml: string, detailUrl: string, sourceCategory: SourceCategory): ScrapedCase | null { - const firstPhotoMatch = detailHtml.match(/]*src=["']([^"']*imagench\/[^"']+)["'][^>]*>/i); - const infoTableHtml = extractPrimaryInfoTable(detailHtml); - const lines = normalizeContactLines(stripHtmlToLines(infoTableHtml)); - - if (!lines.length) { - return null; - } - - const name = extractName(lines); - if (/\bFUE\s+ENCONTRAD[AO]S?\b/i.test(name)) { - return null; +async function scrapeCases(): Promise { + const entryMap = new Map[number]>(); + const foundExternalIds = new Set(); + + for (let offset = 0; ; offset += PAGE_SIZE) { + const listUrl = `${GALLERY_URL}?limite=${PAGE_SIZE}&offset=${offset}&situacionBusqueda=${SOURCE_CATEGORY}`; + console.log(`[${SOURCE_CATEGORY}] Descargando listado offset=${offset}...`); + const listHtml = await fetchText(listUrl); + const pageEntries = parseGalleryEntries(listHtml); + if (!pageEntries.length) { + break; + } + for (const entry of pageEntries) { + entryMap.set(entry.sourceExternalId, entry); + if (entry.isFound) { + foundExternalIds.add(entry.sourceExternalId); + } + } + console.log(`[${SOURCE_CATEGORY}] Fichas en página: ${pageEntries.length} (acumuladas: ${entryMap.size})`); + if (pageEntries.length < PAGE_SIZE) { + break; + } } - return { - name, - age: extractAge(lines), - photoUrl: firstPhotoMatch?.[1] ? absoluteUrl(firstPhotoMatch[1]) : null, - lastSeenDate: extractMissingSinceDate(lines), - city: extractResidenceCity(lines), - contactInfo: lines, - moreInfoUrl: detailUrl, - reportUrl: null, - sourceCategory, - }; -} - -async function scrapeCategory(category: SourceCategory): Promise { - const listUrl = `${BASE_URL}/listado.php?categoria=${category}`; - console.log(`[${category}] Descargando listado...`); - const listHtml = await fetchText(listUrl); - const entries = parseListingEntries(listHtml); - console.log(`[${category}] Fichas encontradas: ${entries.length}`); + const entries = [...entryMap.values()]; + console.log(`[${SOURCE_CATEGORY}] Fichas encontradas: ${entries.length}`); const results: ScrapedCase[] = []; let processed = 0; for (const entry of entries) { processed += 1; + if (entry.isFound || entry.isPlaceholderPhoto) { + continue; + } try { const detailHtml = await fetchText(entry.detailUrl); - const parsed = parseDetailCase(detailHtml, entry.detailUrl, category); + const parsed = parseDetailCase(detailHtml, entry); if (parsed?.photoUrl) { results.push(parsed); } @@ -288,62 +99,91 @@ async function scrapeCategory(category: SourceCategory): Promise console.warn(`Skipping ${entry.detailUrl}: ${String(error)}`); } if (processed === entries.length || processed % 10 === 0) { - console.log(`[${category}] Progreso: ${processed}/${entries.length} (válidos: ${results.length})`); + console.log(`[${SOURCE_CATEGORY}] Progreso: ${processed}/${entries.length} (válidos: ${results.length})`); } } - console.log(`[${category}] Completado: ${results.length} casos válidos`); - return results; + console.log(`[${SOURCE_CATEGORY}] Completado: ${results.length} casos válidos`); + return { cases: results, foundExternalIds }; } -async function main() { - try { - const scrapedCases: ScrapedCase[] = []; - for (const category of SOURCE_CATEGORIES) { - const byCategory = await scrapeCategory(category); - scrapedCases.push(...byCategory); - } +export async function runMissingChildrenIngest() { + const scrapeResult = await scrapeCases(); + const scrapedCases = scrapeResult.cases; - if (!scrapedCases.length) { - throw new Error("No se pudieron extraer casos desde Missing Children."); + if (!scrapedCases.length) { + throw new Error("No se pudieron extraer casos desde Missing Children."); + } + + await withIngestTransaction(async (tx) => { + const orgId = + (await findOrganizationByWebsite(BASE_URL, tx)) ?? + (await insertOrganization({ name: "Missing Children Argentina", website: BASE_URL }, tx)); + + const existingCases = await findManagedCasesForOrganization(orgId, tx); + const existingByExternalId = new Map(); + for (const existingCase of existingCases) { + if (existingCase.sourceExternalId != null) { + existingByExternalId.set(existingCase.sourceExternalId, existingCase); + } } - await withIngestTransaction(async (tx) => { - const orgId = - (await findOrganizationByWebsite(BASE_URL, tx)) ?? - (await insertOrganization({ name: "Missing Children Argentina", website: BASE_URL }, tx)); + const syncDecision = planCaseSync({ + existingCases, + activeExternalIds: scrapedCases.map((entry) => entry.sourceExternalId), + foundExternalIds: scrapeResult.foundExternalIds, + deleteMissingAfterDays: DELETE_MISSING_AFTER_DAYS, + }); - await clearCasesForWebsiteOrLegacy(BASE_URL, tx); + await deleteCasesByIds(syncDecision.caseIdsToDelete, tx); - for (const entry of scrapedCases) { - if (!entry.photoUrl) { - continue; - } - await insertCase( - { - organizationId: orgId, - name: entry.name, - age: entry.age, - photoUrl: entry.photoUrl, - lastSeenDate: entry.lastSeenDate, - city: entry.city, - moreInfoUrl: entry.moreInfoUrl, - reportUrl: entry.reportUrl, - sourceCategory: entry.sourceCategory, - contactInfo: entry.contactInfo.join("\n"), - }, - tx, - ); + for (const entry of scrapedCases) { + if (!entry.photoUrl) { + continue; } - }); + const payload = { + organizationId: orgId, + name: entry.name, + sourceExternalId: entry.sourceExternalId, + age: entry.age, + gender: entry.gender, + birthDate: entry.birthDate, + photoUrl: entry.photoUrl, + lastSeenDate: entry.lastSeenDate, + city: entry.city, + moreInfoUrl: entry.moreInfoUrl, + reportUrl: entry.reportUrl, + sourceCategory: entry.sourceCategory, + contactInfo: entry.contactInfo.join("\n"), + }; + + const existingCase = existingByExternalId.get(entry.sourceExternalId); + if (existingCase) { + await updateCase(existingCase.id, payload, tx); + continue; + } + + await insertCase(payload, tx); + } + }); + + console.log(`Ingest complete (${scrapedCases.length} casos)`); + return { caseCount: scrapedCases.length }; +} - console.log(`Ingest complete (${scrapedCases.length} casos)`); +async function main() { + try { + await runMissingChildrenIngest(); } finally { await pool.end(); } } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +const isDirectExecution = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false; + +if (isDirectExecution) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/apps/api/src/lib/missing-children-parser.ts b/apps/api/src/lib/missing-children-parser.ts new file mode 100644 index 0000000..35f06b7 --- /dev/null +++ b/apps/api/src/lib/missing-children-parser.ts @@ -0,0 +1,226 @@ +import { load, type CheerioAPI } from "cheerio"; + +export type SourceCategory = "perdidos"; + +export type GalleryEntry = { + sourceExternalId: number; + detailUrl: string; + listingName: string; + listingPhotoUrl: string | null; + listingLastSeenDate: string | null; + listingCity: string | null; + gender: string | null; + isFound: boolean; + isPlaceholderPhoto: boolean; +}; + +export type ScrapedCase = { + sourceExternalId: number; + name: string; + age: number | null; + gender: string | null; + birthDate: string | null; + photoUrl: string | null; + lastSeenDate: string | null; + city: string | null; + contactInfo: string[]; + moreInfoUrl: string; + reportUrl: string | null; + sourceCategory: SourceCategory; +}; + +const ROOT_URL = "https://www.missingchildren.org.ar"; +const PAGES_URL = `${ROOT_URL}/pages/`; + +function normalizeText(input: string | null | undefined): string { + return input?.replace(/\s+/g, " ").trim() ?? ""; +} + +function toAbsoluteUrl(pathOrUrl: string | null | undefined, baseUrl = ROOT_URL): string | null { + if (!pathOrUrl) { + return null; + } + return new URL(pathOrUrl, `${baseUrl}/`).toString(); +} + +function extractExternalId(detailUrl: string): number | null { + const id = new URL(detailUrl, PAGES_URL).searchParams.get("id"); + if (!id) { + return null; + } + const parsed = Number.parseInt(id, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function isFoundText(value: string): boolean { + return /fue\s+encontrad[ao]s?/i.test(value); +} + +function isPlaceholderPhoto(url: string | null): boolean { + const value = url?.toLowerCase() ?? ""; + return value.includes("banderaverde") || value.includes("sinimagen") || value.includes("/sin."); +} + +function parseAge(value: string | undefined): number | null { + const match = normalizeText(value).match(/([0-9]{1,3})/); + return match ? Number.parseInt(match[1], 10) : null; +} + +export function parseDayMonthYearToIso(raw: string | null | undefined): string | null { + const text = normalizeText(raw); + const match = text.match(/^([0-9]{2})-([0-9]{2})-([0-9]{4})$/); + if (!match) { + return null; + } + + const day = Number.parseInt(match[1], 10); + const month = Number.parseInt(match[2], 10); + const year = Number.parseInt(match[3], 10); + const candidate = new Date(Date.UTC(year, month - 1, day)); + + if ( + candidate.getUTCFullYear() !== year || + candidate.getUTCMonth() !== month - 1 || + candidate.getUTCDate() !== day + ) { + return null; + } + + return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +function extractFieldMap($: CheerioAPI): Map { + const fields = new Map(); + + $(".dato-item").each((_, element) => { + const item = $(element); + const label = normalizeText(item.find(".h4-datos").first().text()); + const value = normalizeText(item.find(".valor-dato").first().text()); + if (label && value && !fields.has(label)) { + fields.set(label, value); + } + }); + + return fields; +} + +function extractContactInfo($: CheerioAPI): string[] { + const lines: string[] = []; + const footer = $("footer.custom-footer").first(); + + footer.find("a[href^='https://wa.me/'], a[href^='mailto:']").each((_, element) => { + const link = $(element); + const href = link.attr("href") ?? ""; + const text = normalizeText(link.text()); + if (href.startsWith("mailto:")) { + lines.push(text || href.slice("mailto:".length)); + return; + } + if (text) { + lines.push(text); + } + }); + + return [...new Set(lines)]; +} + +function extractWindowOpenUrl(onclick: string | null | undefined): string | null { + const source = onclick ?? ""; + const prefix = "window.open("; + const start = source.indexOf(prefix); + if (start < 0) { + return null; + } + + const firstQuote = source.indexOf("'", start + prefix.length); + if (firstQuote < 0) { + return null; + } + const secondQuote = source.indexOf("'", firstQuote + 1); + if (secondQuote < 0) { + return null; + } + + return toAbsoluteUrl(source.slice(firstQuote + 1, secondQuote), PAGES_URL); +} + +function extractReportUrl($: CheerioAPI): string | null { + let reportUrl: string | null = null; + + $("a, button").each((_, element) => { + if (reportUrl) { + return; + } + const node = $(element); + const label = normalizeText(node.text()); + if (!/brindar información/i.test(label)) { + return; + } + reportUrl = extractWindowOpenUrl(node.attr("onclick")); + }); + + return reportUrl; +} + +export function parseGalleryEntries(listHtml: string): GalleryEntry[] { + const $ = load(listHtml); + const entries = new Map(); + + $(".chico-card_galeria").each((_, element) => { + const card = $(element); + const href = card.find("a[href*='detalles.php?id=']").first().attr("href"); + const detailUrl = toAbsoluteUrl(href, PAGES_URL); + if (!detailUrl) { + return; + } + + const sourceExternalId = extractExternalId(detailUrl); + if (!sourceExternalId || entries.has(sourceExternalId)) { + return; + } + + const listingName = normalizeText(card.find(".card-title_galeria").text()) || normalizeText(card.attr("data-nombre")); + const listingPhotoUrl = toAbsoluteUrl(card.find("img").first().attr("src")); + + entries.set(sourceExternalId, { + sourceExternalId, + detailUrl, + listingName, + listingPhotoUrl, + listingLastSeenDate: parseDayMonthYearToIso(card.find(".card-fecha .fecha-negrita").text()), + listingCity: normalizeText(card.find(".card-lugar").text()) || normalizeText(card.attr("data-lugar")) || null, + gender: normalizeText(card.attr("data-genero")) || null, + isFound: isFoundText(listingName), + isPlaceholderPhoto: isPlaceholderPhoto(listingPhotoUrl), + }); + }); + + return [...entries.values()]; +} + +export function parseDetailCase(detailHtml: string, entry: GalleryEntry): ScrapedCase | null { + const $ = load(detailHtml); + const fields = extractFieldMap($); + const name = normalizeText($(".nombre-chico").first().text()) || entry.listingName || "Sin nombre"; + const photoUrl = + toAbsoluteUrl($("img.img-chico, img.img-chico-mobile").first().attr("src")) ?? entry.listingPhotoUrl; + + if (entry.isFound || isFoundText(name) || isPlaceholderPhoto(photoUrl)) { + return null; + } + + return { + sourceExternalId: entry.sourceExternalId, + name, + age: parseAge(fields.get("Edad en la foto")), + gender: normalizeText(fields.get("Género")) || entry.gender, + birthDate: parseDayMonthYearToIso(fields.get("Fecha de nacimiento")), + photoUrl, + lastSeenDate: parseDayMonthYearToIso(fields.get("Ausente desde")) ?? entry.listingLastSeenDate, + city: normalizeText(fields.get("Lugar de residencia")) || entry.listingCity || null, + contactInfo: extractContactInfo($), + moreInfoUrl: entry.detailUrl, + reportUrl: extractReportUrl($), + sourceCategory: "perdidos", + }; +} \ No newline at end of file diff --git a/apps/api/src/lib/missing-children-scheduler.ts b/apps/api/src/lib/missing-children-scheduler.ts new file mode 100644 index 0000000..b261212 --- /dev/null +++ b/apps/api/src/lib/missing-children-scheduler.ts @@ -0,0 +1,83 @@ +type LoggerLike = { + info(message: string): void; + warn(message: string): void; + error(message: string, error?: unknown): void; +}; + +type IntervalHandle = ReturnType; + +export class MissingChildrenScheduler { + private intervalHandle: IntervalHandle | null = null; + private isRunning = false; + + constructor( + private readonly options: { + enabled: boolean; + intervalMs: number; + logger: LoggerLike; + countCases: () => Promise; + runIngest: () => Promise; + setIntervalFn?: (callback: () => void, delayMs: number) => IntervalHandle; + clearIntervalFn?: (handle: IntervalHandle) => void; + }, + ) {} + + async start() { + if (!this.options.enabled) { + this.options.logger.info("Missing Children scheduler disabled"); + return; + } + + if (this.intervalHandle) { + return; + } + + const setIntervalFn = this.options.setIntervalFn ?? setInterval; + this.intervalHandle = setIntervalFn(() => { + void this.runCycle("interval"); + }, this.options.intervalMs); + + this.options.logger.info(`Missing Children scheduler started (interval=${this.options.intervalMs}ms)`); + + try { + const caseCount = await this.options.countCases(); + if (caseCount === 0) { + this.options.logger.info("No local cases found; triggering startup ingest"); + await this.runCycle("startup-empty-db"); + } else { + this.options.logger.info(`Skipping startup ingest; found ${caseCount} local cases`); + } + } catch (error) { + this.options.logger.error("Failed to evaluate startup ingest condition", error); + } + } + + stop() { + if (!this.intervalHandle) { + return; + } + + const clearIntervalFn = this.options.clearIntervalFn ?? clearInterval; + clearIntervalFn(this.intervalHandle); + this.intervalHandle = null; + this.options.logger.info("Missing Children scheduler stopped"); + } + + private async runCycle(reason: string) { + if (this.isRunning) { + this.options.logger.warn(`Skipping Missing Children ingest (${reason}); previous run still in progress`); + return; + } + + this.isRunning = true; + try { + this.options.logger.info(`Starting Missing Children ingest (${reason})`); + await this.options.runIngest(); + this.options.logger.info(`Missing Children ingest complete (${reason})`); + } catch (error) { + this.options.logger.error(`Missing Children ingest failed (${reason})`, error); + } finally { + this.isRunning = false; + } + } +} \ No newline at end of file diff --git a/apps/api/src/lib/missing-children-sync.ts b/apps/api/src/lib/missing-children-sync.ts new file mode 100644 index 0000000..185ec8f --- /dev/null +++ b/apps/api/src/lib/missing-children-sync.ts @@ -0,0 +1,43 @@ +export type ExistingManagedCase = { + id: string; + sourceExternalId: number | null; + updatedAt: Date; +}; + +export type SyncDecision = { + caseIdsToDelete: string[]; +}; + +export function computeStaleCutoff(now: Date, deleteMissingAfterDays: number): Date { + return new Date(now.getTime() - deleteMissingAfterDays * 24 * 60 * 60 * 1000); +} + +export function planCaseSync(params: { + existingCases: ExistingManagedCase[]; + activeExternalIds: Iterable; + foundExternalIds: Iterable; + deleteMissingAfterDays: number; + now?: Date; +}): SyncDecision { + const now = params.now ?? new Date(); + const staleCutoff = computeStaleCutoff(now, params.deleteMissingAfterDays); + const activeExternalIds = new Set(params.activeExternalIds); + const foundExternalIds = new Set(params.foundExternalIds); + + return { + caseIdsToDelete: params.existingCases + .filter((entry) => { + if (entry.sourceExternalId == null) { + return true; + } + if (foundExternalIds.has(entry.sourceExternalId)) { + return true; + } + if (activeExternalIds.has(entry.sourceExternalId)) { + return false; + } + return entry.updatedAt <= staleCutoff; + }) + .map((entry) => entry.id), + }; +} \ No newline at end of file diff --git a/apps/api/src/repositories/challenge-repository.ts b/apps/api/src/repositories/challenge-repository.ts index d3bf146..240d61e 100644 --- a/apps/api/src/repositories/challenge-repository.ts +++ b/apps/api/src/repositories/challenge-repository.ts @@ -5,9 +5,6 @@ import { dbOrTx } from "../db/executor.js"; import { cases, challenges, siteKeys } from "../db/schema.js"; import type { DbTx } from "../db/types.js"; -const CATEGORY_POOL = ["perdidos", "buscan", "mayores"] as const; -export type SourceCategory = (typeof CATEGORY_POOL)[number]; - export async function findSiteKeyByPublicKey(publicKey: string) { const rows = await db .select({ @@ -23,29 +20,6 @@ export async function findSiteKeyByPublicKey(publicKey: string) { return rows[0] ?? null; } -export function pickRandomCategory(): SourceCategory { - return CATEGORY_POOL[Math.floor(Math.random() * CATEGORY_POOL.length)]; -} - -export async function findActiveCasesByCategory(category: SourceCategory, limit: number) { - return await db - .select({ - id: cases.id, - name: cases.name, - age: cases.age, - photoUrl: cases.photoUrl, - lastSeenDate: cases.lastSeenDate, - lastSeenLocationCity: cases.lastSeenLocationCity, - contactInfo: cases.contactInfo, - moreInfoUrl: cases.moreInfoUrl, - reportUrl: cases.reportUrl, - }) - .from(cases) - .where(and(eq(cases.status, "active"), eq(cases.sourceCategory, category))) - .orderBy(desc(cases.updatedAt)) - .limit(limit); -} - export async function findActiveCases(limit: number) { return await db .select({ diff --git a/apps/api/src/repositories/ingest-repository.ts b/apps/api/src/repositories/ingest-repository.ts index ad0adfd..eaa7302 100644 --- a/apps/api/src/repositories/ingest-repository.ts +++ b/apps/api/src/repositories/ingest-repository.ts @@ -19,6 +19,14 @@ export async function findOrganizationByWebsite(website: string, tx?: DbTx): Pro return rows[0]?.id ?? null; } +export async function countCases(tx?: DbTx): Promise { + const rows = await dbOrTx(tx) + .select({ total: sql`count(*)` }) + .from(cases); + + return Number(rows[0]?.total ?? 0); +} + export async function insertOrganization(params: { name: string; website: string }, tx: DbTx): Promise { const rows = await dbOrTx(tx) .insert(organizations) @@ -28,53 +36,35 @@ export async function insertOrganization(params: { name: string; website: string return rows[0].id; } -async function findCaseIdsToReplace(website: string, tx: DbTx): Promise { - const orgRows = await dbOrTx(tx) - .select({ id: organizations.id }) - .from(organizations) - .where(eq(organizations.website, website)); - - const orgIds = orgRows.map((row: { id: string }) => row.id); - const caseRows = await dbOrTx(tx) - .select({ id: cases.id }) +export async function findManagedCasesForOrganization(organizationId: string, tx: DbTx) { + return await dbOrTx(tx) + .select({ + id: cases.id, + sourceExternalId: cases.sourceExternalId, + updatedAt: cases.updatedAt, + }) .from(cases) - .where( - or( - orgIds.length ? inArray(cases.organizationId, orgIds) : sql`false`, - isNull(cases.sourceCategory), - ), - ); - - return caseRows.map((row: { id: string }) => row.id); + .where(or(eq(cases.organizationId, organizationId), isNull(cases.sourceCategory))); } -export async function clearCasesForWebsiteOrLegacy(website: string, tx: DbTx) { - const caseIds = await findCaseIdsToReplace(website, tx); - if (caseIds.length > 0) { - await dbOrTx(tx).delete(challenges).where(inArray(challenges.caseId, caseIds)); - await dbOrTx(tx).delete(events).where(inArray(events.caseId, caseIds)); +export async function deleteCasesByIds(caseIds: string[], tx: DbTx) { + if (!caseIds.length) { + return; } - const orgRows = await dbOrTx(tx) - .select({ id: organizations.id }) - .from(organizations) - .where(eq(organizations.website, website)); - const orgIds = orgRows.map((row: { id: string }) => row.id); - - if (orgIds.length > 0) { - await dbOrTx(tx) - .delete(cases) - .where(or(inArray(cases.organizationId, orgIds), isNull(cases.sourceCategory))); - } else { - await dbOrTx(tx).delete(cases).where(isNull(cases.sourceCategory)); - } + await dbOrTx(tx).delete(challenges).where(inArray(challenges.caseId, caseIds)); + await dbOrTx(tx).delete(events).where(inArray(events.caseId, caseIds)); + await dbOrTx(tx).delete(cases).where(inArray(cases.id, caseIds)); } export async function insertCase( params: { organizationId: string; + sourceExternalId: number; name: string; age: number | null; + gender: string | null; + birthDate: string | null; photoUrl: string; lastSeenDate: string | null; city: string | null; @@ -86,10 +76,14 @@ export async function insertCase( tx: DbTx, ) { const parsedLastSeenDate = params.lastSeenDate ? new Date(`${params.lastSeenDate}T00:00:00.000Z`) : null; + const parsedBirthDate = params.birthDate ? new Date(`${params.birthDate}T00:00:00.000Z`) : null; await dbOrTx(tx).insert(cases).values({ organizationId: params.organizationId, + sourceExternalId: params.sourceExternalId, name: params.name, age: params.age, + gender: params.gender, + birthDate: parsedBirthDate, photoUrl: params.photoUrl, lastSeenDate: parsedLastSeenDate, lastSeenLocationCity: params.city, @@ -100,3 +94,43 @@ export async function insertCase( status: "active", }); } + +export async function updateCase( + caseId: string, + params: { + name: string; + age: number | null; + gender: string | null; + birthDate: string | null; + photoUrl: string; + lastSeenDate: string | null; + city: string | null; + moreInfoUrl: string; + reportUrl: string | null; + sourceCategory: string; + contactInfo: string; + }, + tx: DbTx, +) { + const parsedLastSeenDate = params.lastSeenDate ? new Date(`${params.lastSeenDate}T00:00:00.000Z`) : null; + const parsedBirthDate = params.birthDate ? new Date(`${params.birthDate}T00:00:00.000Z`) : null; + + await dbOrTx(tx) + .update(cases) + .set({ + name: params.name, + age: params.age, + gender: params.gender, + birthDate: parsedBirthDate, + photoUrl: params.photoUrl, + lastSeenDate: parsedLastSeenDate, + lastSeenLocationCity: params.city, + moreInfoUrl: params.moreInfoUrl, + reportUrl: params.reportUrl, + sourceCategory: params.sourceCategory, + contactInfo: params.contactInfo, + status: "active", + updatedAt: sql`NOW()`, + }) + .where(eq(cases.id, caseId)); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index e2e6b6e..bb6d701 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -3,9 +3,21 @@ import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import { env } from "./env.js"; +import { countCases } from "./repositories/ingest-repository.js"; import { v1Routes } from "./routes/v1.js"; +import { MissingChildrenScheduler } from "./lib/missing-children-scheduler.js"; +import { runMissingChildrenIngest } from "./jobs/ingest_missing_children.js"; const app = Fastify({ logger: true }); +const missingChildrenScheduler = new MissingChildrenScheduler({ + enabled: env.INGEST_SCHEDULER_ENABLED, + intervalMs: env.INGEST_SCHEDULER_INTERVAL_MS, + logger: app.log, + countCases, + runIngest: async () => { + await runMissingChildrenIngest(); + }, +}); await app.register(cors, { origin: env.ALLOWED_ORIGINS.includes("*") ? true : env.ALLOWED_ORIGINS, @@ -24,6 +36,7 @@ await app.register(v1Routes, { prefix: "/v1" }); const closeSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; for (const signal of closeSignals) { process.on(signal, async () => { + missingChildrenScheduler.stop(); await app.close(); process.exit(0); }); @@ -33,6 +46,7 @@ app .listen({ port: env.PORT, host: "0.0.0.0" }) .then(() => { app.log.info(`API started on ${env.PORT}`); + void missingChildrenScheduler.start(); }) .catch((error) => { app.log.error(error); diff --git a/apps/api/src/services/challenge-service.ts b/apps/api/src/services/challenge-service.ts index 66856a2..3de7a35 100644 --- a/apps/api/src/services/challenge-service.ts +++ b/apps/api/src/services/challenge-service.ts @@ -8,13 +8,11 @@ import { hmacSign, randomId, safeEqual } from "../lib/crypto.js"; import { consumeChallenge, findActiveCases, - findActiveCasesByCategory, findCaseById, findChallengeForUpdate, findSiteKeyByPublicKey, insertChallenge, lockOrIncrementFailedAttempt, - pickRandomCategory, withChallengeTransaction, } from "../repositories/challenge-repository.js"; @@ -78,11 +76,7 @@ export async function createChallenge(params: { return { errorCode: "DOMAIN_NOT_ALLOWED", status: 403 as const }; } - const randomCategory = pickRandomCategory(); - let caseRows = await findActiveCasesByCategory(randomCategory, 25); - if (!caseRows.length) { - caseRows = await findActiveCases(25); - } + const caseRows = await findActiveCases(25); if (!caseRows.length) { return { errorCode: "NO_CASES_AVAILABLE", status: 503 as const }; } diff --git a/apps/api/test/missing-children-live.integration.ts b/apps/api/test/missing-children-live.integration.ts new file mode 100644 index 0000000..2ab06a6 --- /dev/null +++ b/apps/api/test/missing-children-live.integration.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseDetailCase, parseGalleryEntries } from "../src/lib/missing-children-parser.js"; + +const USER_AGENT = "missing-captcha-integration-test/1.0"; +const LISTING_URL = + "https://www.missingchildren.org.ar/pages/galeria_ajax.php?limite=12&offset=0&situacionBusqueda=perdidos"; + +async function fetchText(url: string, timeoutMs = 20000): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + "user-agent": USER_AGENT, + "accept-language": "es-AR,es;q=0.9", + }, + }); + + assert.equal(response.ok, true, `expected 200-ish response for ${url}, got ${response.status}`); + return await response.text(); + } finally { + clearTimeout(timeout); + } +} + +test("live missingchildren integration parses one gallery card and its detail page", { timeout: 30000 }, async () => { + const listingHtml = await fetchText(LISTING_URL); + const entries = parseGalleryEntries(listingHtml); + + assert.ok(entries.length > 0, "expected at least one gallery entry"); + + const candidate = entries.find((entry) => !entry.isFound && !entry.isPlaceholderPhoto); + assert.ok(candidate, "expected at least one active gallery entry with a real photo"); + + assert.ok(candidate.sourceExternalId > 0, "expected sourceExternalId from gallery entry"); + assert.match(candidate.detailUrl, /detalles\.php\?id=\d+/); + assert.ok(candidate.listingName.length > 0, "expected non-empty listing name"); + + const detailHtml = await fetchText(candidate.detailUrl); + const parsed = parseDetailCase(detailHtml, candidate); + + assert.ok(parsed, "expected detail parser to return a case"); + assert.equal(parsed?.sourceExternalId, candidate.sourceExternalId); + assert.equal(parsed?.moreInfoUrl, candidate.detailUrl); + assert.ok((parsed?.name?.length ?? 0) > 0, "expected parsed name"); + assert.ok((parsed?.photoUrl?.length ?? 0) > 0, "expected parsed photo URL"); + assert.equal(parsed?.sourceCategory, "perdidos"); +}); \ No newline at end of file diff --git a/apps/api/test/missing-children-parser.test.ts b/apps/api/test/missing-children-parser.test.ts new file mode 100644 index 0000000..fb32f7a --- /dev/null +++ b/apps/api/test/missing-children-parser.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseDayMonthYearToIso, + parseDetailCase, + parseGalleryEntries, +} from "../src/lib/missing-children-parser.js"; + +test("parseGalleryEntries reads gallery cards and found markers", () => { + const html = ` +
+ +
Nasly Fue Encontrada
+

Perdido desde: 17-03-2026

+

San Lorenzo

+
+
+ +
Thiago Yutiel PAEZ
+

Perdido desde: 06-03-2026

+

Villa Fiorito, Lomas de Zamora

+
`; + + const entries = parseGalleryEntries(html); + assert.equal(entries.length, 2); + assert.equal(entries[0].sourceExternalId, 5423); + assert.equal(entries[0].isFound, true); + assert.equal(entries[0].isPlaceholderPhoto, true); + assert.equal(entries[1].sourceExternalId, 5437); + assert.equal(entries[1].listingLastSeenDate, "2026-03-06"); + assert.equal(entries[1].listingCity, "Villa Fiorito, Lomas de Zamora"); + assert.equal(entries[1].gender, "masculino"); +}); + +test("parseDetailCase extracts canonical case data from detail HTML", () => { + const [entry] = parseGalleryEntries(` +
+ +
Thiago Yutiel PAEZ
+

Perdido desde: 06-03-2026

+

Villa Fiorito, Lomas de Zamora

+
`); + + const detailHtml = ` +
Thiago Yutiel PAEZ
+ +

Ausente desde

06-03-2026

+

Lugar de residencia

Villa Fiorito, Lomas de Zamora, GBA Sur

+

Edad en la foto

8 años

+

Género

masculino

+

Fecha de nacimiento

24-02-2018

+ + `; + + const parsed = parseDetailCase(detailHtml, entry); + assert.ok(parsed); + assert.equal(parsed?.sourceExternalId, 5437); + assert.equal(parsed?.name, "Thiago Yutiel PAEZ"); + assert.equal(parsed?.age, 8); + assert.equal(parsed?.gender, "masculino"); + assert.equal(parsed?.birthDate, "2018-02-24"); + assert.equal(parsed?.lastSeenDate, "2026-03-06"); + assert.equal(parsed?.city, "Villa Fiorito, Lomas de Zamora, GBA Sur"); + assert.deepEqual(parsed?.contactInfo, ["+54 911 4517 3101", "info@missingchildren.org.ar"]); + assert.equal(parsed?.reportUrl, "https://wa.me/541141573101?text=Hola"); + assert.equal(parsed?.sourceCategory, "perdidos"); +}); + +test("parseDayMonthYearToIso rejects malformed dates", () => { + assert.equal(parseDayMonthYearToIso("24-02-2018"), "2018-02-24"); + assert.equal(parseDayMonthYearToIso("30-11--0001"), null); + assert.equal(parseDayMonthYearToIso("31-02-2026"), null); +}); \ No newline at end of file diff --git a/apps/api/test/missing-children-scheduler.test.ts b/apps/api/test/missing-children-scheduler.test.ts new file mode 100644 index 0000000..5d58be7 --- /dev/null +++ b/apps/api/test/missing-children-scheduler.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { MissingChildrenScheduler } from "../src/lib/missing-children-scheduler.js"; + +function createLogger() { + return { + infoMessages: [] as string[], + warnMessages: [] as string[], + errorMessages: [] as string[], + info(message: string) { + this.infoMessages.push(message); + }, + warn(message: string) { + this.warnMessages.push(message); + }, + error(message: string) { + this.errorMessages.push(message); + }, + }; +} + +test("scheduler runs startup ingest only when there are no cases", async () => { + const logger = createLogger(); + let runCount = 0; + let tick: (() => void) | null = null; + + const scheduler = new MissingChildrenScheduler({ + enabled: true, + intervalMs: 1000, + logger, + countCases: async () => 0, + runIngest: async () => { + runCount += 1; + }, + setIntervalFn: (callback) => { + tick = callback; + return {} as ReturnType; + }, + clearIntervalFn: () => {}, + }); + + await scheduler.start(); + assert.equal(runCount, 1); + assert.ok(tick); + + tick?.(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(runCount, 2); +}); + +test("scheduler skips startup ingest when local cases already exist", async () => { + const logger = createLogger(); + let runCount = 0; + + const scheduler = new MissingChildrenScheduler({ + enabled: true, + intervalMs: 1000, + logger, + countCases: async () => 4, + runIngest: async () => { + runCount += 1; + }, + setIntervalFn: () => ({}) as ReturnType, + clearIntervalFn: () => {}, + }); + + await scheduler.start(); + assert.equal(runCount, 0); + assert.ok(logger.infoMessages.some((message) => message.includes("Skipping startup ingest"))); +}); + +test("scheduler does not overlap runs", async () => { + const logger = createLogger(); + let tick: (() => void) | null = null; + let resolveRun: (() => void) | null = null; + let runCount = 0; + + const scheduler = new MissingChildrenScheduler({ + enabled: true, + intervalMs: 1000, + logger, + countCases: async () => 1, + runIngest: async () => { + runCount += 1; + await new Promise((resolve) => { + resolveRun = resolve; + }); + }, + setIntervalFn: (callback) => { + tick = callback; + return {} as ReturnType; + }, + clearIntervalFn: () => {}, + }); + + await scheduler.start(); + tick?.(); + await new Promise((resolve) => setImmediate(resolve)); + tick?.(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(runCount, 1); + assert.ok(logger.warnMessages.some((message) => message.includes("still in progress"))); + + resolveRun?.(); + await new Promise((resolve) => setImmediate(resolve)); +}); + +test("scheduler respects the enabled flag", async () => { + const logger = createLogger(); + let runCount = 0; + + const scheduler = new MissingChildrenScheduler({ + enabled: false, + intervalMs: 1000, + logger, + countCases: async () => 0, + runIngest: async () => { + runCount += 1; + }, + setIntervalFn: () => ({}) as ReturnType, + clearIntervalFn: () => {}, + }); + + await scheduler.start(); + assert.equal(runCount, 0); + assert.ok(logger.infoMessages.some((message) => message.includes("disabled"))); +}); \ No newline at end of file diff --git a/apps/api/test/missing-children-sync.test.ts b/apps/api/test/missing-children-sync.test.ts new file mode 100644 index 0000000..075abbb --- /dev/null +++ b/apps/api/test/missing-children-sync.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { computeStaleCutoff, planCaseSync } from "../src/lib/missing-children-sync.js"; + +test("computeStaleCutoff subtracts the configured day threshold", () => { + const now = new Date("2026-04-07T12:00:00.000Z"); + assert.equal(computeStaleCutoff(now, 30).toISOString(), "2026-03-08T12:00:00.000Z"); +}); + +test("planCaseSync deletes legacy rows, found rows, and stale missing rows", () => { + const decision = planCaseSync({ + existingCases: [ + { id: "legacy", sourceExternalId: null, updatedAt: new Date("2026-04-01T00:00:00.000Z") }, + { id: "found", sourceExternalId: 10, updatedAt: new Date("2026-04-01T00:00:00.000Z") }, + { id: "stale-missing", sourceExternalId: 11, updatedAt: new Date("2026-02-20T00:00:00.000Z") }, + { id: "recent-missing", sourceExternalId: 12, updatedAt: new Date("2026-03-20T00:00:00.000Z") }, + { id: "active", sourceExternalId: 13, updatedAt: new Date("2026-02-20T00:00:00.000Z") }, + ], + activeExternalIds: [13], + foundExternalIds: [10], + deleteMissingAfterDays: 30, + now: new Date("2026-04-07T00:00:00.000Z"), + }); + + assert.deepEqual(decision.caseIdsToDelete.sort(), ["found", "legacy", "stale-missing"]); +}); + +test("planCaseSync keeps missing rows that are still within the freshness threshold", () => { + const decision = planCaseSync({ + existingCases: [{ id: "recent", sourceExternalId: 25, updatedAt: new Date("2026-03-15T00:00:00.000Z") }], + activeExternalIds: [], + foundExternalIds: [], + deleteMissingAfterDays: 30, + now: new Date("2026-04-07T00:00:00.000Z"), + }); + + assert.deepEqual(decision.caseIdsToDelete, []); +}); \ No newline at end of file diff --git a/demo/index.html b/demo/index.html deleted file mode 100644 index ce9f1b7..0000000 --- a/demo/index.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - Missing Captcha Demo - - - - -
-

Missing Captcha Demo

-
-
- - - diff --git a/package-lock.json b/package-lock.json index 41afc84..0273b96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^5.6.0", "@missing-captcha/shared": "0.1.0", + "cheerio": "^1.2.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.45.1", "fastify": "^5.2.1", @@ -785,12 +786,60 @@ "fastq": "^1.17.1" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -804,6 +853,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -822,6 +899,61 @@ "node": ">=6" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -968,6 +1100,31 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1180,6 +1337,37 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1200,6 +1388,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1407,6 +1607,18 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/obliterator": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", @@ -1422,6 +1634,55 @@ "node": ">=14.0.0" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/pg": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", @@ -1710,6 +1971,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -2338,6 +2605,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -2354,6 +2630,28 @@ "node": ">= 0.8" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",