From a82231c80c2a45c38b0082c6a0a5809703cbde15 Mon Sep 17 00:00:00 2001 From: Diego Calero Date: Tue, 7 Apr 2026 11:24:09 -0300 Subject: [PATCH 01/10] Initial commit with the plan. --- INGESTOR-UPGRADE.md | 400 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 INGESTOR-UPGRADE.md diff --git a/INGESTOR-UPGRADE.md b/INGESTOR-UPGRADE.md new file mode 100644 index 0000000..8b2e09c --- /dev/null +++ b/INGESTOR-UPGRADE.md @@ -0,0 +1,400 @@ +# Ingestor upgrade plan for the new Missing Children website + +## Goal + +Replace the legacy ingestion flow based on: + +- `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` + +with a new flow based on the current website entrypoint: + +- `https://www.missingchildren.org.ar/pages/galeria.php` + +This document is planning-only. No code changes are proposed here. + +## What changed on the source site + +### 1) The public entry page is no longer the real list source +`galeria.php` renders filters and JS, but the actual cards are loaded from: + +- `https://www.missingchildren.org.ar/pages/galeria_ajax.php?limite=&offset=` + +So the crawler should target `galeria_ajax.php`, not scrape the static HTML of `galeria.php`. + +### 2) The source site still exposes categories, but our app can simplify to one +The new filter values observed on the page are: + +- `perdidos` +- `busqueda_familia` +- `mayores` + +For our app, the plan is to ingest only `perdidos` and remove category-driven behavior from the runtime flow. + +Important implications: + +- the old code still assumes `buscan`, so category-related code will need cleanup +- challenge selection can stop depending on category pools +- we may still keep `source_category` in the DB for compatibility and future-proofing, but it can be stored as a constant `perdidos` for this source + +### 3) Detail pages still exist and are the best source of canonical case data +Each gallery card links to: + +- `https://www.missingchildren.org.ar/pages/detalles.php?id=` + +The detail page currently exposes structured case data such as: + +- name +- photo +- `Ausente desde` +- `Lugar de residencia` +- `Edad en la foto` +- `Género` +- `Fecha de nacimiento` +- WhatsApp CTA (`Brindar información`) + +### 4) The gallery includes records we probably do not want to serve +Observed edge cases: + +- cards whose title says `Fue Encontrada` / `Fue Encontrado` +- green-flag images like `banderaverde.jpg` +- placeholder images like `sinimagen1.jpg` / `sin.jpg` +- incomplete fields such as blank location or invalid birth dates like `30-11--0001` + +## Current app impact + +### Existing ingestion code +Current logic lives in: + +- `apps/api/src/jobs/ingest_missing_children.ts` + +It is tightly coupled to the old table-based pages and old detail-page format. + +It also currently clears all prior Missing Children rows and re-inserts everything on each run. That should be replaced with an incremental sync strategy. + +### Existing persistence model +Current `cases` columns used by the widget/API: + +- `name` +- `age` +- `photo_url` +- `last_seen_date` +- `last_seen_location_city` +- `contact_info` +- `more_info_url` +- `report_url` +- `status` +- `source_category` + +Relevant files: + +- `apps/api/src/db/schema.ts` +- `apps/api/src/repositories/ingest-repository.ts` +- `apps/api/src/repositories/challenge-repository.ts` +- `apps/api/src/services/challenge-service.ts` + +## Field mapping: new site -> current DB + +### Fields that still map well +- **name** -> `name` +- **Edad en la foto** -> `age` +- **main image URL** -> `photo_url` +- **Ausente desde** -> `last_seen_date` +- **Lugar de residencia** -> `last_seen_location_city` +- **detail page URL** -> `more_info_url` +- **constant source marker (`perdidos`)** -> `source_category` if we keep it for compatibility + +### Fields that changed meaning or need adaptation +- **contact info**: the old site exposed a contact block; the new site exposes a WhatsApp CTA plus generic footer contact data. + - Keep `contact_info`, populated with normalized fallback lines such as phone + email. + - Also capture the per-case WhatsApp URL into `report_url`. +- **report URL**: there is no obvious "report page", but the WhatsApp CTA is a strong equivalent for `report_url`. + +## Recommended ingestion strategy + +### Phase 1: enumerate cards from `galeria_ajax.php` +Use only the `perdidos` filter: + +- `galeria_ajax.php?limite=100&offset=0&situacionBusqueda=perdidos` +- then increment `offset` until the response is empty + +Why this approach: + +- it matches how the site itself paginates +- it simplifies downstream app logic by treating this source as a single stream of active missing cases +- it avoids depending on JS execution in our ingestor + +Important note: `galeria_ajax.php` returns **HTML fragments, not JSON**. We still need HTML parsing for both the gallery cards and the detail pages. + +Chosen approach: use a **real HTML parser**, not regex-based scraping, for both gallery and detail extraction. + +### Phase 2: parse gallery cards only for discovery and quick filters +From each card, extract at least: + +- detail URL / external ID (`detalles.php?id=`) +- title/name +- image URL +- gallery date text +- gallery location text +- gender when present (`data-genero`) + +Use the gallery stage to: + +- deduplicate by detail-page ID +- skip obvious "found" entries before fetching details +- skip obvious placeholder images +- set `source_category` to `perdidos` if we decide to keep that column populated + +### Phase 3: fetch each detail page for the canonical record +Use the detail page as the authoritative source for persisted case data. + +Parse label/value blocks instead of scraping the whole page as raw text. Target the repeated `dato-item` structure and normalize keys like: + +- `Ausente desde` +- `Lugar de residencia` +- `Edad en la foto` +- `Género` +- `Fecha de nacimiento` + +Also extract: + +- WhatsApp URL from the `Brindar información` button + +### Phase 4: normalize and filter before insert +Suggested rules: + +- convert `DD-MM-YYYY` to ISO for `last_seen_date` +- parse integer from `Edad en la foto` +- normalize `Fecha de nacimiento` into `birth_date` when it is parseable; otherwise store it as empty / `NULL` +- exclude cases marked as found (`Fue Encontrad*`, green-flag image, or equivalent signal) +- exclude placeholder photos +- this plan no longer depends on the other categories + +### Phase 5: incremental sync by `source_external_id` +Do **not** delete everything and re-insert on every ingest. + +Instead: + +1. persist the numeric `detalles.php?id=` as `source_external_id` +2. load current DB rows for Missing Children keyed by `source_external_id` +3. build an external snapshot keyed by `source_external_id` +4. for each external case that is still missing, upsert the local row +5. for each local row whose external case is now marked as found, delete it from our DB +6. for each local row whose `source_external_id` no longer exists in the external snapshot, delete it if its local `updated_at` is older than a configurable threshold (default: 30 days) + +This gives us a stable comparison key and avoids unnecessary churn in `cases`, `challenges`, and related records. + +Important implementation detail: if we use `updated_at` for staleness, every successful sync of a still-active case must refresh `updated_at`. Cases not seen upstream will naturally age out. + +### Found-case deletion policy +We should explicitly track found/not-found state during scraping even if we do not persist found cases. + +Rules: + +- if a case is returned by the source site with the same `source_external_id` but is now marked as found, remove the local case +- if a case was previously active locally but is absent from the active external listing and we can confirm it is now found, remove it +- if a case is absent from the external active snapshot and its local `updated_at` is older than the configurable threshold, remove it automatically + +The important part is that the delete decision must be based on `source_external_id`, not fuzzy matching by name or URL text. + +Suggested config: + +- `INGEST_DELETE_MISSING_AFTER_DAYS=30` + +## Do we need new DB fields? + +### Minimum answer +**Yes: `source_external_id` should be added and treated as required for this migration.** + +Everything else can remain unchanged for a first working migration if we map: + +- detail URL -> `more_info_url` +- WhatsApp CTA -> `report_url` +- normalized phone/email fallback -> `contact_info` +- constant `perdidos` -> `source_category` (if we keep the column) + +### Required addition + +1. **`source_external_id`** + - value: the numeric `id` from `detalles.php?id=` + - benefit: stable dedupe key, stable upsert key, and the key we need to remove cases that are now found + +2. **`gender`** + - available on the new detail page and gallery cards + - useful for richer metadata and future filtering or display + +3. **`birth_date`** + - available on the new detail page + - if the source value cannot be normalized, store `NULL` / empty + +### Recommended optional additions +There are no additional recommended columns beyond the required set above for the current scope. + +### Not recommended for now +Avoid adding first-class columns yet for: + +- province / region split +- gallery range buckets (`data-rango`) +- `Tipo de foto` +- `poster_url` + +These are either ambiguous, low value for the widget, or not clearly stable. + +## Non-schema code changes that will still be required later + +- update the ingestor parsing logic to the new endpoints and DOM structure +- replace full refresh with `source_external_id`-based upsert/delete sync +- simplify the app to a single-source-category flow (`perdidos`) and remove category-based challenge selection logic where possible +- use a real HTML parser instead of regex-heavy parsing +- add an internal once-per-day sync trigger in `apps/api` +- update README ingestion docs after implementation + +## Scheduling / daily sync + +We can run the once-per-day sync **inside `apps/api`** so no extra external scheduler is required. + +Preferred plan: + +- keep the ingest job as a normal executable script that can still be run manually +- add an internal scheduler in the API process that triggers the ingest once every 24 hours +- trigger one sync on startup **only if** we currently have no cases in the DB, then continue on the daily interval + +Reasoning: + +- the API server is expected to stay up continuously +- it avoids introducing additional infrastructure just for scheduling +- it still preserves manual execution via `make ingest` / `make ingest-docker` + +Possible implementation details later: + +- a timer started from `apps/api/src/server.ts` or a dedicated scheduler module +- a startup check that only kicks off an immediate ingest when the DB has zero cases +- env flags for enable/disable, startup behavior, and run interval + +Assumption for this project: + +- only one API instance will run, so no DB lock / leader mechanism is needed + +## Testing strategy + +We should add tests around the ingestor before and while implementing the migration. + +### Unit / fixture-driven coverage +Add parser-focused tests for as much as possible using saved HTML fixtures for: + +- `perdidos` gallery parsing +- detail-page field extraction +- date normalization +- birth-date normalization to `birth_date` or `NULL` +- found-case detection +- placeholder-image filtering +- sync diff logic (`insert`, `update`, `delete because found`, `delete because missing after threshold`) +- HTML parser behavior against realistic gallery/detail fixtures + +### Repository / sync behavior tests +Add tests for the DB-side sync algorithm to verify: + +- upsert by `source_external_id` +- deleting a local row when the same external ID is now found +- deleting rows that disappear upstream once `updated_at` is older than the configured threshold +- preserving rows that are still active but changed in non-key fields + +### Scheduler behavior tests +Add small tests around the internal scheduler logic to verify: + +- it respects the enabled/disabled flag +- it does not overlap runs +- it runs on startup only when the DB has no cases +- it does not force a startup ingest when cases already exist + +### One live integration test against the external site +Add one intentionally small live test that: + +- fetches one `perdidos` page from `galeria_ajax.php` +- extracts one `source_external_id` / detail link +- fetches that detail page +- asserts the listing and detail parsers both return a minimally valid case + +Because it depends on the public website, this test should be lightweight and isolated. It is still worth having because it gives us an early signal when the upstream HTML changes again. + +## Suggested git commit / stage plan + +### Commit 1: schema foundation +- add `source_external_id` to `cases` +- add `gender` to `cases` +- add `birth_date` to `cases` +- explain in the migration notes that stale deletion will rely on refreshing existing `updated_at` + +Purpose: + +- establish the final data model up front so later commits can focus on ingestion behavior instead of schema churn +- make `source_external_id` the canonical reconciliation key from the beginning +- ensure `gender` and `birth_date` are available as soon as the new detail parser lands + +### Commit 2: parser migration +- replace the legacy listing fetch with `galeria_ajax.php?situacionBusqueda=perdidos` +- parse gallery HTML fragments and detail HTML pages with a real HTML parser +- map `contact_info` and `report_url` using the new site structure + +Purpose: + +- move the ingestor onto the new website structure without yet changing sync semantics +- make extraction more robust by avoiding regex-driven HTML parsing +- confirm we can reliably parse one consistent canonical case shape from listing + detail pages + +### Commit 3: incremental sync +- replace delete-all/reinsert-all with `source_external_id`-based upsert/delete +- delete records immediately when upstream marks them found +- delete records missing from upstream when local `updated_at` is older than the configured threshold + +Purpose: + +- stop churning the entire dataset on every ingest +- make removals deterministic and explainable +- use `updated_at` as the aging mechanism by refreshing rows that are still present upstream + +### Commit 4: remove category-driven runtime logic +- simplify challenge selection and any repository logic that depends on multiple categories +- keep `source_category` only as a compatibility/traceability field if desired + +Purpose: + +- align the runtime model with the decision to ingest only `perdidos` +- reduce code paths and fallback behavior that are no longer meaningful +- keep `source_category` only if it still provides value for traceability or future extensibility + +### Commit 5: internal daily scheduler +- add the in-process daily ingest scheduler to `apps/api` +- make the interval and deletion threshold configurable + +Purpose: + +- keep the dataset fresh without requiring external cron or platform scheduling +- perform a startup ingest only when the DB is empty, so a fresh deployment self-populates +- keep manual operation available while making routine synchronization automatic + +### Commit 6: tests and documentation +- add fixture-based parser tests +- add sync behavior tests +- add one live external integration test for gallery + detail +- add a `make test-integration` target for the live external integration test +- make `make test-all` include integration coverage too +- update README and operational notes + +Purpose: + +- protect the parser against future upstream HTML changes +- verify the new incremental sync and aging rules +- provide a dedicated command for the live external check while ensuring the full test suite exercises it + +## Recommendation + +Implement the migration in these stages: + +- **Stage 1:** schema foundation (`source_external_id`, `gender`, and `birth_date`). +- **Stage 2:** gallery/detail parser migration for `perdidos` only. +- **Stage 3:** incremental sync and automated deletion rules based on `updated_at` staleness. +- **Stage 4:** remove unnecessary category-driven behavior from the app. +- **Stage 5:** internal daily scheduler with startup sync only when the DB is empty. +- **Stage 6:** test coverage, `make test-integration`, `make test-all` integration coverage, and documentation updates. From 9ff1f7ade8fdd218760d90c75b0ea9ef84a71cee Mon Sep 17 00:00:00 2001 From: Diego Calero Date: Tue, 7 Apr 2026 11:33:16 -0300 Subject: [PATCH 02/10] schema foundation. --- Makefile | 4 +++- apps/api/migrations/003_cases_external_fields.sql | 8 ++++++++ apps/api/src/db/schema.ts | 3 +++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 apps/api/migrations/003_cases_external_fields.sql diff --git a/Makefile b/Makefile index b451224..0b1858d 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,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 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/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"), From 266aa8021f223dcc35dbaefa5371befd3dab317b Mon Sep 17 00:00:00 2001 From: Diego Calero Date: Tue, 7 Apr 2026 11:52:53 -0300 Subject: [PATCH 03/10] parser migration. --- apps/api/package.json | 1 + apps/api/src/jobs/ingest_missing_children.ts | 279 +++------------- apps/api/src/lib/missing-children-parser.ts | 226 +++++++++++++ .../api/src/repositories/ingest-repository.ts | 7 + apps/api/test/missing-children-parser.test.ts | 77 +++++ package-lock.json | 298 ++++++++++++++++++ 6 files changed, 646 insertions(+), 242 deletions(-) create mode 100644 apps/api/src/lib/missing-children-parser.ts create mode 100644 apps/api/test/missing-children-parser.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index fe580c2..ea24321 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,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/jobs/ingest_missing_children.ts b/apps/api/src/jobs/ingest_missing_children.ts index d6757c5..071e34b 100644 --- a/apps/api/src/jobs/ingest_missing_children.ts +++ b/apps/api/src/jobs/ingest_missing_children.ts @@ -6,214 +6,18 @@ import { insertOrganization, 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"; 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); -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()]; -} - async function fetchText(url: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); @@ -240,47 +44,39 @@ 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; - } +async function scrapeCases(): Promise { + const entryMap = new Map[number]>(); - const name = extractName(lines); - if (/\bFUE\s+ENCONTRAD[AO]S?\b/i.test(name)) { - return null; + 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); + } + 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,21 +84,17 @@ 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`); + console.log(`[${SOURCE_CATEGORY}] Completado: ${results.length} casos válidos`); return results; } async function main() { try { - const scrapedCases: ScrapedCase[] = []; - for (const category of SOURCE_CATEGORIES) { - const byCategory = await scrapeCategory(category); - scrapedCases.push(...byCategory); - } + const scrapedCases = await scrapeCases(); if (!scrapedCases.length) { throw new Error("No se pudieron extraer casos desde Missing Children."); @@ -323,7 +115,10 @@ async function main() { { 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, 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/repositories/ingest-repository.ts b/apps/api/src/repositories/ingest-repository.ts index ad0adfd..7fc9ecf 100644 --- a/apps/api/src/repositories/ingest-repository.ts +++ b/apps/api/src/repositories/ingest-repository.ts @@ -73,8 +73,11 @@ export async function clearCasesForWebsiteOrLegacy(website: string, tx: DbTx) { 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 +89,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, 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/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", From 167c7d100d03a881f9754e1c6d54dd150c9bb865 Mon Sep 17 00:00:00 2001 From: Diego Calero Date: Tue, 7 Apr 2026 12:03:41 -0300 Subject: [PATCH 04/10] incremental sync. --- apps/api/src/jobs/ingest_missing_children.ts | 80 ++++++++++++----- apps/api/src/lib/missing-children-sync.ts | 43 +++++++++ .../api/src/repositories/ingest-repository.ts | 89 +++++++++++-------- apps/api/test/missing-children-sync.test.ts | 39 ++++++++ 4 files changed, 193 insertions(+), 58 deletions(-) create mode 100644 apps/api/src/lib/missing-children-sync.ts create mode 100644 apps/api/test/missing-children-sync.test.ts diff --git a/apps/api/src/jobs/ingest_missing_children.ts b/apps/api/src/jobs/ingest_missing_children.ts index 071e34b..1bd68ec 100644 --- a/apps/api/src/jobs/ingest_missing_children.ts +++ b/apps/api/src/jobs/ingest_missing_children.ts @@ -1,9 +1,11 @@ import { pool } from "../db/pool.js"; import { - clearCasesForWebsiteOrLegacy, + deleteCasesByIds, findOrganizationByWebsite, + findManagedCasesForOrganization, insertCase, insertOrganization, + updateCase, withIngestTransaction, } from "../repositories/ingest-repository.js"; import { @@ -11,12 +13,19 @@ import { 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 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); + +type ScrapeResult = { + cases: ScrapedCase[]; + foundExternalIds: Set; +}; async function fetchText(url: string): Promise { const controller = new AbortController(); @@ -44,8 +53,9 @@ async function fetchText(url: string): Promise { return await response.text(); } -async function scrapeCases(): Promise { +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}`; @@ -57,6 +67,9 @@ async function scrapeCases(): Promise { } 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) { @@ -89,12 +102,13 @@ async function scrapeCases(): Promise { } console.log(`[${SOURCE_CATEGORY}] Completado: ${results.length} casos válidos`); - return results; + return { cases: results, foundExternalIds }; } async function main() { try { - const scrapedCases = await scrapeCases(); + const scrapeResult = await scrapeCases(); + const scrapedCases = scrapeResult.cases; if (!scrapedCases.length) { throw new Error("No se pudieron extraer casos desde Missing Children."); @@ -105,30 +119,50 @@ async function main() { (await findOrganizationByWebsite(BASE_URL, tx)) ?? (await insertOrganization({ name: "Missing Children Argentina", website: BASE_URL }, tx)); - await clearCasesForWebsiteOrLegacy(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); + } + } + + const syncDecision = planCaseSync({ + existingCases, + activeExternalIds: scrapedCases.map((entry) => entry.sourceExternalId), + foundExternalIds: scrapeResult.foundExternalIds, + deleteMissingAfterDays: DELETE_MISSING_AFTER_DAYS, + }); + + await deleteCasesByIds(syncDecision.caseIdsToDelete, tx); for (const entry of scrapedCases) { if (!entry.photoUrl) { continue; } - await insertCase( - { - 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"), - }, - tx, - ); + 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); } }); 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/ingest-repository.ts b/apps/api/src/repositories/ingest-repository.ts index 7fc9ecf..5ccb6a3 100644 --- a/apps/api/src/repositories/ingest-repository.ts +++ b/apps/api/src/repositories/ingest-repository.ts @@ -28,46 +28,25 @@ 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( @@ -107,3 +86,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/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 From 94be290c821445588998ac39ed26de5e7e878e14 Mon Sep 17 00:00:00 2001 From: Diego Calero Date: Tue, 7 Apr 2026 12:08:03 -0300 Subject: [PATCH 05/10] remove category-driven runtime logic. --- .../src/repositories/challenge-repository.ts | 26 ------------------- apps/api/src/services/challenge-service.ts | 8 +----- 2 files changed, 1 insertion(+), 33 deletions(-) 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/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 }; } From dd84281c9c5b503a06fe62cd2e70844a83821b96 Mon Sep 17 00:00:00 2001 From: Diego Calero Date: Tue, 7 Apr 2026 12:17:18 -0300 Subject: [PATCH 06/10] internal daily scheduler. --- .env.example | 3 + apps/api/src/env.ts | 2 + apps/api/src/jobs/ingest_missing_children.ts | 131 ++++++++++-------- .../api/src/lib/missing-children-scheduler.ts | 83 +++++++++++ .../api/src/repositories/ingest-repository.ts | 8 ++ apps/api/src/server.ts | 14 ++ .../test/missing-children-scheduler.test.ts | 129 +++++++++++++++++ 7 files changed, 310 insertions(+), 60 deletions(-) create mode 100644 apps/api/src/lib/missing-children-scheduler.ts create mode 100644 apps/api/test/missing-children-scheduler.test.ts 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/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 1bd68ec..8812d74 100644 --- a/apps/api/src/jobs/ingest_missing_children.ts +++ b/apps/api/src/jobs/ingest_missing_children.ts @@ -1,3 +1,5 @@ +import { pathToFileURL } from "node:url"; + import { pool } from "../db/pool.js"; import { deleteCasesByIds, @@ -105,74 +107,83 @@ async function scrapeCases(): Promise { return { cases: results, foundExternalIds }; } -async function main() { - try { - const scrapeResult = await scrapeCases(); - const scrapedCases = scrapeResult.cases; +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 deleteCasesByIds(syncDecision.caseIdsToDelete, 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); + const existingCases = await findManagedCasesForOrganization(orgId, tx); + const existingByExternalId = new Map(); + for (const existingCase of existingCases) { + if (existingCase.sourceExternalId != null) { + existingByExternalId.set(existingCase.sourceExternalId, existingCase); } + } + + const syncDecision = planCaseSync({ + existingCases, + activeExternalIds: scrapedCases.map((entry) => entry.sourceExternalId), + foundExternalIds: scrapeResult.foundExternalIds, + deleteMissingAfterDays: DELETE_MISSING_AFTER_DAYS, }); - console.log(`Ingest complete (${scrapedCases.length} casos)`); + await deleteCasesByIds(syncDecision.caseIdsToDelete, 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 }; +} + +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-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/repositories/ingest-repository.ts b/apps/api/src/repositories/ingest-repository.ts index 5ccb6a3..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) 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/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 From e4ca2133ee872395cca19289ae54b4da386e9834 Mon Sep 17 00:00:00 2001 From: Diego Calero Date: Tue, 7 Apr 2026 12:23:31 -0300 Subject: [PATCH 07/10] tests and documentation. --- Makefile | 7 ++- README.md | 34 +++++++++--- apps/api/package.json | 1 + .../test/missing-children-live.integration.ts | 52 +++++++++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 apps/api/test/missing-children-live.integration.ts diff --git a/Makefile b/Makefile index 0b1858d..887e844 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 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)" @@ -35,9 +36,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 diff --git a/README.md b/README.md index 7ab5b31..e288fd1 100644 --- a/README.md +++ b/README.md @@ -51,17 +51,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 +123,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/package.json b/apps/api/package.json index ea24321..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": { 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 From 002e041a0f1b28f540ff665a7fe0f08d93b98d1a Mon Sep 17 00:00:00 2001 From: Diego Calero Date: Tue, 7 Apr 2026 12:28:26 -0300 Subject: [PATCH 08/10] Updating the final version of the plan before deleting it. --- INGESTOR-UPGRADE.md | 149 +++++++++++++++++++++++--------------------- 1 file changed, 79 insertions(+), 70 deletions(-) diff --git a/INGESTOR-UPGRADE.md b/INGESTOR-UPGRADE.md index 8b2e09c..65918fd 100644 --- a/INGESTOR-UPGRADE.md +++ b/INGESTOR-UPGRADE.md @@ -1,4 +1,4 @@ -# Ingestor upgrade plan for the new Missing Children website +# Ingestor upgrade summary for the new Missing Children website ## Goal @@ -12,7 +12,7 @@ with a new flow based on the current website entrypoint: - `https://www.missingchildren.org.ar/pages/galeria.php` -This document is planning-only. No code changes are proposed here. +This document reflects the implemented migration status after upgrading the ingestor and related runtime behavior. ## What changed on the source site @@ -30,13 +30,12 @@ The new filter values observed on the page are: - `busqueda_familia` - `mayores` -For our app, the plan is to ingest only `perdidos` and remove category-driven behavior from the runtime flow. +For our app, the implementation ingests only `perdidos` and removes category-driven behavior from the runtime flow. -Important implications: +Implemented implications: -- the old code still assumes `buscan`, so category-related code will need cleanup -- challenge selection can stop depending on category pools -- we may still keep `source_category` in the DB for compatibility and future-proofing, but it can be stored as a constant `perdidos` for this source +- challenge selection no longer depends on category pools +- `source_category` is still stored for compatibility/traceability and is populated as constant `perdidos` for this source ### 3) Detail pages still exist and are the best source of canonical case data Each gallery card links to: @@ -64,20 +63,24 @@ Observed edge cases: ## Current app impact -### Existing ingestion code +### Ingestion code Current logic lives in: - `apps/api/src/jobs/ingest_missing_children.ts` +- `apps/api/src/lib/missing-children-parser.ts` +- `apps/api/src/lib/missing-children-sync.ts` +- `apps/api/src/lib/missing-children-scheduler.ts` -It is tightly coupled to the old table-based pages and old detail-page format. +The old table-based parser and full-refresh sync were removed. The ingestor now uses the new gallery/detail pages, a real HTML parser, and incremental synchronization. -It also currently clears all prior Missing Children rows and re-inserts everything on each run. That should be replaced with an incremental sync strategy. - -### Existing persistence model -Current `cases` columns used by the widget/API: +### Persistence model +Current `cases` columns used by the widget/API / ingest flow: +- `source_external_id` - `name` - `age` +- `gender` +- `birth_date` - `photo_url` - `last_seen_date` - `last_seen_location_city` @@ -111,7 +114,7 @@ Relevant files: - Also capture the per-case WhatsApp URL into `report_url`. - **report URL**: there is no obvious "report page", but the WhatsApp CTA is a strong equivalent for `report_url`. -## Recommended ingestion strategy +## Implemented ingestion strategy ### Phase 1: enumerate cards from `galeria_ajax.php` Use only the `perdidos` filter: @@ -127,7 +130,7 @@ Why this approach: Important note: `galeria_ajax.php` returns **HTML fragments, not JSON**. We still need HTML parsing for both the gallery cards and the detail pages. -Chosen approach: use a **real HTML parser**, not regex-based scraping, for both gallery and detail extraction. +Implemented approach: use a **real HTML parser** (`cheerio`), not regex-based scraping, for both gallery and detail extraction. ### Phase 2: parse gallery cards only for discovery and quick filters From each card, extract at least: @@ -139,15 +142,15 @@ From each card, extract at least: - gallery location text - gender when present (`data-genero`) -Use the gallery stage to: +The implemented gallery stage is used to: - deduplicate by detail-page ID - skip obvious "found" entries before fetching details - skip obvious placeholder images -- set `source_category` to `perdidos` if we decide to keep that column populated +- set `source_category` to constant `perdidos` ### Phase 3: fetch each detail page for the canonical record -Use the detail page as the authoritative source for persisted case data. +The detail page is the authoritative source for persisted case data. Parse label/value blocks instead of scraping the whole page as raw text. Target the repeated `dato-item` structure and normalize keys like: @@ -161,8 +164,8 @@ Also extract: - WhatsApp URL from the `Brindar información` button -### Phase 4: normalize and filter before insert -Suggested rules: +### Phase 4: normalize and filter before insert/update +Implemented rules: - convert `DD-MM-YYYY` to ISO for `last_seen_date` - parse integer from `Edad en la foto` @@ -172,9 +175,9 @@ Suggested rules: - this plan no longer depends on the other categories ### Phase 5: incremental sync by `source_external_id` -Do **not** delete everything and re-insert on every ingest. +The ingestor does **not** delete everything and re-insert on every ingest. -Instead: +Implemented behavior: 1. persist the numeric `detalles.php?id=` as `source_external_id` 2. load current DB rows for Missing Children keyed by `source_external_id` @@ -188,7 +191,7 @@ This gives us a stable comparison key and avoids unnecessary churn in `cases`, ` Important implementation detail: if we use `updated_at` for staleness, every successful sync of a still-active case must refresh `updated_at`. Cases not seen upstream will naturally age out. ### Found-case deletion policy -We should explicitly track found/not-found state during scraping even if we do not persist found cases. +The ingestor explicitly tracks found/not-found state during scraping even though found cases are not persisted. Rules: @@ -198,23 +201,23 @@ Rules: The important part is that the delete decision must be based on `source_external_id`, not fuzzy matching by name or URL text. -Suggested config: +Current config: - `INGEST_DELETE_MISSING_AFTER_DAYS=30` -## Do we need new DB fields? +## DB field outcome -### Minimum answer -**Yes: `source_external_id` should be added and treated as required for this migration.** +### Implemented additions +The migration added the required fields below. -Everything else can remain unchanged for a first working migration if we map: +The existing fields are still used with these mappings: - detail URL -> `more_info_url` - WhatsApp CTA -> `report_url` - normalized phone/email fallback -> `contact_info` - constant `perdidos` -> `source_category` (if we keep the column) -### Required addition +### Added fields 1. **`source_external_id`** - value: the numeric `id` from `detalles.php?id=` @@ -228,8 +231,8 @@ Everything else can remain unchanged for a first working migration if we map: - available on the new detail page - if the source value cannot be normalized, store `NULL` / empty -### Recommended optional additions -There are no additional recommended columns beyond the required set above for the current scope. +### Not added +There are no additional columns beyond the implemented set above for the current scope. ### Not recommended for now Avoid adding first-class columns yet for: @@ -241,24 +244,24 @@ Avoid adding first-class columns yet for: These are either ambiguous, low value for the widget, or not clearly stable. -## Non-schema code changes that will still be required later +## Implemented non-schema changes -- update the ingestor parsing logic to the new endpoints and DOM structure -- replace full refresh with `source_external_id`-based upsert/delete sync -- simplify the app to a single-source-category flow (`perdidos`) and remove category-based challenge selection logic where possible -- use a real HTML parser instead of regex-heavy parsing -- add an internal once-per-day sync trigger in `apps/api` -- update README ingestion docs after implementation +- migrated the ingestor to `galeria_ajax.php` + `detalles.php` +- replaced regex-heavy parsing with a `cheerio` parser module +- replaced full refresh with `source_external_id`-based upsert/delete sync +- simplified runtime case selection to a single `perdidos` flow +- added an internal once-per-day sync trigger in `apps/api` +- updated README ingestion and testing docs ## Scheduling / daily sync -We can run the once-per-day sync **inside `apps/api`** so no extra external scheduler is required. +The once-per-day sync now runs **inside `apps/api`**, so no extra external scheduler is required. -Preferred plan: +Implemented behavior: - keep the ingest job as a normal executable script that can still be run manually - add an internal scheduler in the API process that triggers the ingest once every 24 hours -- trigger one sync on startup **only if** we currently have no cases in the DB, then continue on the daily interval +- trigger one sync on startup **only if** the DB currently has no cases, then continue on the daily interval Reasoning: @@ -266,22 +269,22 @@ Reasoning: - it avoids introducing additional infrastructure just for scheduling - it still preserves manual execution via `make ingest` / `make ingest-docker` -Possible implementation details later: +Current implementation details: -- a timer started from `apps/api/src/server.ts` or a dedicated scheduler module +- a scheduler started from `apps/api/src/server.ts` - a startup check that only kicks off an immediate ingest when the DB has zero cases -- env flags for enable/disable, startup behavior, and run interval +- env flags for enable/disable and run interval Assumption for this project: - only one API instance will run, so no DB lock / leader mechanism is needed -## Testing strategy +## Implemented testing strategy -We should add tests around the ingestor before and while implementing the migration. +The repo now includes tests around the ingestor, sync rules, scheduler, and live upstream parsing. -### Unit / fixture-driven coverage -Add parser-focused tests for as much as possible using saved HTML fixtures for: +### Parser / unit coverage +Implemented coverage includes: - `perdidos` gallery parsing - detail-page field extraction @@ -290,10 +293,10 @@ Add parser-focused tests for as much as possible using saved HTML fixtures for: - found-case detection - placeholder-image filtering - sync diff logic (`insert`, `update`, `delete because found`, `delete because missing after threshold`) -- HTML parser behavior against realistic gallery/detail fixtures +- HTML parser behavior against representative gallery/detail HTML snippets -### Repository / sync behavior tests -Add tests for the DB-side sync algorithm to verify: +### Sync behavior tests +Implemented tests verify: - upsert by `source_external_id` - deleting a local row when the same external ID is now found @@ -301,26 +304,32 @@ Add tests for the DB-side sync algorithm to verify: - preserving rows that are still active but changed in non-key fields ### Scheduler behavior tests -Add small tests around the internal scheduler logic to verify: +Implemented tests verify: - it respects the enabled/disabled flag - it does not overlap runs - it runs on startup only when the DB has no cases - it does not force a startup ingest when cases already exist -### One live integration test against the external site -Add one intentionally small live test that: +### Live integration test against the external site +Implemented live test: - fetches one `perdidos` page from `galeria_ajax.php` - extracts one `source_external_id` / detail link - fetches that detail page - asserts the listing and detail parsers both return a minimally valid case -Because it depends on the public website, this test should be lightweight and isolated. It is still worth having because it gives us an early signal when the upstream HTML changes again. +Because it depends on the public website, this test is intentionally lightweight and isolated. It gives us an early signal when the upstream HTML changes again. + +### Current commands + +- `make test` runs the regular workspace test suites +- `make test-integration` runs the live Missing Children integration test locally +- `make test-all` runs dockerized lint + tests + the live integration test -## Suggested git commit / stage plan +## Implemented git commit / stage summary -### Commit 1: schema foundation +### Commit 1: schema foundation ✅ - add `source_external_id` to `cases` - add `gender` to `cases` - add `birth_date` to `cases` @@ -332,7 +341,7 @@ Purpose: - make `source_external_id` the canonical reconciliation key from the beginning - ensure `gender` and `birth_date` are available as soon as the new detail parser lands -### Commit 2: parser migration +### Commit 2: parser migration ✅ - replace the legacy listing fetch with `galeria_ajax.php?situacionBusqueda=perdidos` - parse gallery HTML fragments and detail HTML pages with a real HTML parser - map `contact_info` and `report_url` using the new site structure @@ -343,7 +352,7 @@ Purpose: - make extraction more robust by avoiding regex-driven HTML parsing - confirm we can reliably parse one consistent canonical case shape from listing + detail pages -### Commit 3: incremental sync +### Commit 3: incremental sync ✅ - replace delete-all/reinsert-all with `source_external_id`-based upsert/delete - delete records immediately when upstream marks them found - delete records missing from upstream when local `updated_at` is older than the configured threshold @@ -354,7 +363,7 @@ Purpose: - make removals deterministic and explainable - use `updated_at` as the aging mechanism by refreshing rows that are still present upstream -### Commit 4: remove category-driven runtime logic +### Commit 4: remove category-driven runtime logic ✅ - simplify challenge selection and any repository logic that depends on multiple categories - keep `source_category` only as a compatibility/traceability field if desired @@ -364,7 +373,7 @@ Purpose: - reduce code paths and fallback behavior that are no longer meaningful - keep `source_category` only if it still provides value for traceability or future extensibility -### Commit 5: internal daily scheduler +### Commit 5: internal daily scheduler ✅ - add the in-process daily ingest scheduler to `apps/api` - make the interval and deletion threshold configurable @@ -374,7 +383,7 @@ Purpose: - perform a startup ingest only when the DB is empty, so a fresh deployment self-populates - keep manual operation available while making routine synchronization automatic -### Commit 6: tests and documentation +### Commit 6: tests and documentation ✅ - add fixture-based parser tests - add sync behavior tests - add one live external integration test for gallery + detail @@ -388,13 +397,13 @@ Purpose: - verify the new incremental sync and aging rules - provide a dedicated command for the live external check while ensuring the full test suite exercises it -## Recommendation +## Final status -Implement the migration in these stages: +All planned stages were implemented: -- **Stage 1:** schema foundation (`source_external_id`, `gender`, and `birth_date`). -- **Stage 2:** gallery/detail parser migration for `perdidos` only. -- **Stage 3:** incremental sync and automated deletion rules based on `updated_at` staleness. -- **Stage 4:** remove unnecessary category-driven behavior from the app. -- **Stage 5:** internal daily scheduler with startup sync only when the DB is empty. -- **Stage 6:** test coverage, `make test-integration`, `make test-all` integration coverage, and documentation updates. +- **Stage 1:** schema foundation (`source_external_id`, `gender`, and `birth_date`) ✅ +- **Stage 2:** gallery/detail parser migration for `perdidos` only ✅ +- **Stage 3:** incremental sync and automated deletion rules based on `updated_at` staleness ✅ +- **Stage 4:** remove unnecessary category-driven behavior from the app ✅ +- **Stage 5:** internal daily scheduler with startup sync only when the DB is empty ✅ +- **Stage 6:** test coverage, `make test-integration`, `make test-all` integration coverage, and documentation updates ✅ From 94293cffe2031e1bd28dd580144f931a69800ded Mon Sep 17 00:00:00 2001 From: Diego Calero Date: Tue, 7 Apr 2026 13:18:06 -0300 Subject: [PATCH 09/10] Adjusting the `demo` makefiles. --- .gitignore | 1 + Makefile | 30 +++++++++++++++++++----------- README.md | 11 ++++++++++- demo/index.html | 27 --------------------------- 4 files changed, 30 insertions(+), 39 deletions(-) delete mode 100644 demo/index.html 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 887e844..fab7e3e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ SHELL ?= /bin/zsh DEMO_PORT ?= 3000 -.PHONY: help install build lint test test-integration 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:" @@ -21,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 @@ -84,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 e288fd1..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 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

-
-
- - - From c3c8fbc47cad68d5c92fb30bce13befccd7ca441 Mon Sep 17 00:00:00 2001 From: Diego Calero Date: Tue, 7 Apr 2026 13:18:33 -0300 Subject: [PATCH 10/10] Removing the plan file. --- INGESTOR-UPGRADE.md | 409 -------------------------------------------- 1 file changed, 409 deletions(-) delete mode 100644 INGESTOR-UPGRADE.md diff --git a/INGESTOR-UPGRADE.md b/INGESTOR-UPGRADE.md deleted file mode 100644 index 65918fd..0000000 --- a/INGESTOR-UPGRADE.md +++ /dev/null @@ -1,409 +0,0 @@ -# Ingestor upgrade summary for the new Missing Children website - -## Goal - -Replace the legacy ingestion flow based on: - -- `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` - -with a new flow based on the current website entrypoint: - -- `https://www.missingchildren.org.ar/pages/galeria.php` - -This document reflects the implemented migration status after upgrading the ingestor and related runtime behavior. - -## What changed on the source site - -### 1) The public entry page is no longer the real list source -`galeria.php` renders filters and JS, but the actual cards are loaded from: - -- `https://www.missingchildren.org.ar/pages/galeria_ajax.php?limite=&offset=` - -So the crawler should target `galeria_ajax.php`, not scrape the static HTML of `galeria.php`. - -### 2) The source site still exposes categories, but our app can simplify to one -The new filter values observed on the page are: - -- `perdidos` -- `busqueda_familia` -- `mayores` - -For our app, the implementation ingests only `perdidos` and removes category-driven behavior from the runtime flow. - -Implemented implications: - -- challenge selection no longer depends on category pools -- `source_category` is still stored for compatibility/traceability and is populated as constant `perdidos` for this source - -### 3) Detail pages still exist and are the best source of canonical case data -Each gallery card links to: - -- `https://www.missingchildren.org.ar/pages/detalles.php?id=` - -The detail page currently exposes structured case data such as: - -- name -- photo -- `Ausente desde` -- `Lugar de residencia` -- `Edad en la foto` -- `Género` -- `Fecha de nacimiento` -- WhatsApp CTA (`Brindar información`) - -### 4) The gallery includes records we probably do not want to serve -Observed edge cases: - -- cards whose title says `Fue Encontrada` / `Fue Encontrado` -- green-flag images like `banderaverde.jpg` -- placeholder images like `sinimagen1.jpg` / `sin.jpg` -- incomplete fields such as blank location or invalid birth dates like `30-11--0001` - -## Current app impact - -### Ingestion code -Current logic lives in: - -- `apps/api/src/jobs/ingest_missing_children.ts` -- `apps/api/src/lib/missing-children-parser.ts` -- `apps/api/src/lib/missing-children-sync.ts` -- `apps/api/src/lib/missing-children-scheduler.ts` - -The old table-based parser and full-refresh sync were removed. The ingestor now uses the new gallery/detail pages, a real HTML parser, and incremental synchronization. - -### Persistence model -Current `cases` columns used by the widget/API / ingest flow: - -- `source_external_id` -- `name` -- `age` -- `gender` -- `birth_date` -- `photo_url` -- `last_seen_date` -- `last_seen_location_city` -- `contact_info` -- `more_info_url` -- `report_url` -- `status` -- `source_category` - -Relevant files: - -- `apps/api/src/db/schema.ts` -- `apps/api/src/repositories/ingest-repository.ts` -- `apps/api/src/repositories/challenge-repository.ts` -- `apps/api/src/services/challenge-service.ts` - -## Field mapping: new site -> current DB - -### Fields that still map well -- **name** -> `name` -- **Edad en la foto** -> `age` -- **main image URL** -> `photo_url` -- **Ausente desde** -> `last_seen_date` -- **Lugar de residencia** -> `last_seen_location_city` -- **detail page URL** -> `more_info_url` -- **constant source marker (`perdidos`)** -> `source_category` if we keep it for compatibility - -### Fields that changed meaning or need adaptation -- **contact info**: the old site exposed a contact block; the new site exposes a WhatsApp CTA plus generic footer contact data. - - Keep `contact_info`, populated with normalized fallback lines such as phone + email. - - Also capture the per-case WhatsApp URL into `report_url`. -- **report URL**: there is no obvious "report page", but the WhatsApp CTA is a strong equivalent for `report_url`. - -## Implemented ingestion strategy - -### Phase 1: enumerate cards from `galeria_ajax.php` -Use only the `perdidos` filter: - -- `galeria_ajax.php?limite=100&offset=0&situacionBusqueda=perdidos` -- then increment `offset` until the response is empty - -Why this approach: - -- it matches how the site itself paginates -- it simplifies downstream app logic by treating this source as a single stream of active missing cases -- it avoids depending on JS execution in our ingestor - -Important note: `galeria_ajax.php` returns **HTML fragments, not JSON**. We still need HTML parsing for both the gallery cards and the detail pages. - -Implemented approach: use a **real HTML parser** (`cheerio`), not regex-based scraping, for both gallery and detail extraction. - -### Phase 2: parse gallery cards only for discovery and quick filters -From each card, extract at least: - -- detail URL / external ID (`detalles.php?id=`) -- title/name -- image URL -- gallery date text -- gallery location text -- gender when present (`data-genero`) - -The implemented gallery stage is used to: - -- deduplicate by detail-page ID -- skip obvious "found" entries before fetching details -- skip obvious placeholder images -- set `source_category` to constant `perdidos` - -### Phase 3: fetch each detail page for the canonical record -The detail page is the authoritative source for persisted case data. - -Parse label/value blocks instead of scraping the whole page as raw text. Target the repeated `dato-item` structure and normalize keys like: - -- `Ausente desde` -- `Lugar de residencia` -- `Edad en la foto` -- `Género` -- `Fecha de nacimiento` - -Also extract: - -- WhatsApp URL from the `Brindar información` button - -### Phase 4: normalize and filter before insert/update -Implemented rules: - -- convert `DD-MM-YYYY` to ISO for `last_seen_date` -- parse integer from `Edad en la foto` -- normalize `Fecha de nacimiento` into `birth_date` when it is parseable; otherwise store it as empty / `NULL` -- exclude cases marked as found (`Fue Encontrad*`, green-flag image, or equivalent signal) -- exclude placeholder photos -- this plan no longer depends on the other categories - -### Phase 5: incremental sync by `source_external_id` -The ingestor does **not** delete everything and re-insert on every ingest. - -Implemented behavior: - -1. persist the numeric `detalles.php?id=` as `source_external_id` -2. load current DB rows for Missing Children keyed by `source_external_id` -3. build an external snapshot keyed by `source_external_id` -4. for each external case that is still missing, upsert the local row -5. for each local row whose external case is now marked as found, delete it from our DB -6. for each local row whose `source_external_id` no longer exists in the external snapshot, delete it if its local `updated_at` is older than a configurable threshold (default: 30 days) - -This gives us a stable comparison key and avoids unnecessary churn in `cases`, `challenges`, and related records. - -Important implementation detail: if we use `updated_at` for staleness, every successful sync of a still-active case must refresh `updated_at`. Cases not seen upstream will naturally age out. - -### Found-case deletion policy -The ingestor explicitly tracks found/not-found state during scraping even though found cases are not persisted. - -Rules: - -- if a case is returned by the source site with the same `source_external_id` but is now marked as found, remove the local case -- if a case was previously active locally but is absent from the active external listing and we can confirm it is now found, remove it -- if a case is absent from the external active snapshot and its local `updated_at` is older than the configurable threshold, remove it automatically - -The important part is that the delete decision must be based on `source_external_id`, not fuzzy matching by name or URL text. - -Current config: - -- `INGEST_DELETE_MISSING_AFTER_DAYS=30` - -## DB field outcome - -### Implemented additions -The migration added the required fields below. - -The existing fields are still used with these mappings: - -- detail URL -> `more_info_url` -- WhatsApp CTA -> `report_url` -- normalized phone/email fallback -> `contact_info` -- constant `perdidos` -> `source_category` (if we keep the column) - -### Added fields - -1. **`source_external_id`** - - value: the numeric `id` from `detalles.php?id=` - - benefit: stable dedupe key, stable upsert key, and the key we need to remove cases that are now found - -2. **`gender`** - - available on the new detail page and gallery cards - - useful for richer metadata and future filtering or display - -3. **`birth_date`** - - available on the new detail page - - if the source value cannot be normalized, store `NULL` / empty - -### Not added -There are no additional columns beyond the implemented set above for the current scope. - -### Not recommended for now -Avoid adding first-class columns yet for: - -- province / region split -- gallery range buckets (`data-rango`) -- `Tipo de foto` -- `poster_url` - -These are either ambiguous, low value for the widget, or not clearly stable. - -## Implemented non-schema changes - -- migrated the ingestor to `galeria_ajax.php` + `detalles.php` -- replaced regex-heavy parsing with a `cheerio` parser module -- replaced full refresh with `source_external_id`-based upsert/delete sync -- simplified runtime case selection to a single `perdidos` flow -- added an internal once-per-day sync trigger in `apps/api` -- updated README ingestion and testing docs - -## Scheduling / daily sync - -The once-per-day sync now runs **inside `apps/api`**, so no extra external scheduler is required. - -Implemented behavior: - -- keep the ingest job as a normal executable script that can still be run manually -- add an internal scheduler in the API process that triggers the ingest once every 24 hours -- trigger one sync on startup **only if** the DB currently has no cases, then continue on the daily interval - -Reasoning: - -- the API server is expected to stay up continuously -- it avoids introducing additional infrastructure just for scheduling -- it still preserves manual execution via `make ingest` / `make ingest-docker` - -Current implementation details: - -- a scheduler started from `apps/api/src/server.ts` -- a startup check that only kicks off an immediate ingest when the DB has zero cases -- env flags for enable/disable and run interval - -Assumption for this project: - -- only one API instance will run, so no DB lock / leader mechanism is needed - -## Implemented testing strategy - -The repo now includes tests around the ingestor, sync rules, scheduler, and live upstream parsing. - -### Parser / unit coverage -Implemented coverage includes: - -- `perdidos` gallery parsing -- detail-page field extraction -- date normalization -- birth-date normalization to `birth_date` or `NULL` -- found-case detection -- placeholder-image filtering -- sync diff logic (`insert`, `update`, `delete because found`, `delete because missing after threshold`) -- HTML parser behavior against representative gallery/detail HTML snippets - -### Sync behavior tests -Implemented tests verify: - -- upsert by `source_external_id` -- deleting a local row when the same external ID is now found -- deleting rows that disappear upstream once `updated_at` is older than the configured threshold -- preserving rows that are still active but changed in non-key fields - -### Scheduler behavior tests -Implemented tests verify: - -- it respects the enabled/disabled flag -- it does not overlap runs -- it runs on startup only when the DB has no cases -- it does not force a startup ingest when cases already exist - -### Live integration test against the external site -Implemented live test: - -- fetches one `perdidos` page from `galeria_ajax.php` -- extracts one `source_external_id` / detail link -- fetches that detail page -- asserts the listing and detail parsers both return a minimally valid case - -Because it depends on the public website, this test is intentionally lightweight and isolated. It gives us an early signal when the upstream HTML changes again. - -### Current commands - -- `make test` runs the regular workspace test suites -- `make test-integration` runs the live Missing Children integration test locally -- `make test-all` runs dockerized lint + tests + the live integration test - -## Implemented git commit / stage summary - -### Commit 1: schema foundation ✅ -- add `source_external_id` to `cases` -- add `gender` to `cases` -- add `birth_date` to `cases` -- explain in the migration notes that stale deletion will rely on refreshing existing `updated_at` - -Purpose: - -- establish the final data model up front so later commits can focus on ingestion behavior instead of schema churn -- make `source_external_id` the canonical reconciliation key from the beginning -- ensure `gender` and `birth_date` are available as soon as the new detail parser lands - -### Commit 2: parser migration ✅ -- replace the legacy listing fetch with `galeria_ajax.php?situacionBusqueda=perdidos` -- parse gallery HTML fragments and detail HTML pages with a real HTML parser -- map `contact_info` and `report_url` using the new site structure - -Purpose: - -- move the ingestor onto the new website structure without yet changing sync semantics -- make extraction more robust by avoiding regex-driven HTML parsing -- confirm we can reliably parse one consistent canonical case shape from listing + detail pages - -### Commit 3: incremental sync ✅ -- replace delete-all/reinsert-all with `source_external_id`-based upsert/delete -- delete records immediately when upstream marks them found -- delete records missing from upstream when local `updated_at` is older than the configured threshold - -Purpose: - -- stop churning the entire dataset on every ingest -- make removals deterministic and explainable -- use `updated_at` as the aging mechanism by refreshing rows that are still present upstream - -### Commit 4: remove category-driven runtime logic ✅ -- simplify challenge selection and any repository logic that depends on multiple categories -- keep `source_category` only as a compatibility/traceability field if desired - -Purpose: - -- align the runtime model with the decision to ingest only `perdidos` -- reduce code paths and fallback behavior that are no longer meaningful -- keep `source_category` only if it still provides value for traceability or future extensibility - -### Commit 5: internal daily scheduler ✅ -- add the in-process daily ingest scheduler to `apps/api` -- make the interval and deletion threshold configurable - -Purpose: - -- keep the dataset fresh without requiring external cron or platform scheduling -- perform a startup ingest only when the DB is empty, so a fresh deployment self-populates -- keep manual operation available while making routine synchronization automatic - -### Commit 6: tests and documentation ✅ -- add fixture-based parser tests -- add sync behavior tests -- add one live external integration test for gallery + detail -- add a `make test-integration` target for the live external integration test -- make `make test-all` include integration coverage too -- update README and operational notes - -Purpose: - -- protect the parser against future upstream HTML changes -- verify the new incremental sync and aging rules -- provide a dedicated command for the live external check while ensuring the full test suite exercises it - -## Final status - -All planned stages were implemented: - -- **Stage 1:** schema foundation (`source_external_id`, `gender`, and `birth_date`) ✅ -- **Stage 2:** gallery/detail parser migration for `perdidos` only ✅ -- **Stage 3:** incremental sync and automated deletion rules based on `updated_at` staleness ✅ -- **Stage 4:** remove unnecessary category-driven behavior from the app ✅ -- **Stage 5:** internal daily scheduler with startup sync only when the DB is empty ✅ -- **Stage 6:** test coverage, `make test-integration`, `make test-all` integration coverage, and documentation updates ✅