From 6829335d836f17d6a9503e61a52f9cc2e56b29f5 Mon Sep 17 00:00:00 2001 From: small-claude <307551610+vpetersson-bot@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:18:13 +0000 Subject: [PATCH] feat: show active AMBER Alerts first Each load now checks NCMEC's active AMBER Alerts (action=amberAlert, scoped to the configured state). While one is active, the board shows the child from it, flagged "AMBER Alert" with the alert date, and pulls the photo and age from the alert's detail record. With no alerts, or if the check fails, the app falls back to a random open case as before. Records naming an abductor or companion are dropped so only the child is ever shown. Co-Authored-By: Claude Opus 5.5 --- .well-known/signage-app.json | 2 +- CLAUDE.md | 13 +++- README.md | 13 ++++ assets/static/js/cases.ts | 106 +++++++++++++++++++++++++++++++- assets/static/js/main.ts | 48 +++++++++++++-- index.html | 4 +- test/cases.test.ts | 113 ++++++++++++++++++++++++++++++++++- 7 files changed, 288 insertions(+), 11 deletions(-) diff --git a/.well-known/signage-app.json b/.well-known/signage-app.json index 782c59d..1127f36 100644 --- a/.well-known/signage-app.json +++ b/.well-known/signage-app.json @@ -2,7 +2,7 @@ "manifestVersion": "1", "id": "missing-children", "name": "Missing Children", - "description": "A full-screen missing-child alert for any display. Each time it loads it shows one child picked at random from the open cases at the National Center for Missing & Exploited Children, nationwide or for one US state, with a photo, the child's age, when and where they went missing, a QR code for the official poster, and the 24-hour 1-800-THE-LOST hotline.", + "description": "A full-screen missing-child alert for any display. Each time it loads it shows one child picked at random from the open cases at the National Center for Missing & Exploited Children, nationwide or for one US state, with a photo, the child's age, when and where they went missing, a QR code for the official poster, and the 24-hour 1-800-THE-LOST hotline. While an AMBER Alert is active for the area, the child from that alert is shown first.", "summary": "Help bring missing children home from any screen.", "vendor": "Screenly", "tags": [ diff --git a/CLAUDE.md b/CLAUDE.md index c2ab5fc..7ad2ce4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,16 @@ HTML/CSS/JS built through `@screenly-labs/signage-kit`. Like `quotes` and and no refresh timer. The player's reload brings the next child. Keep it that way; don't add a client-side loop or anything pushed from a server. +Active **AMBER Alerts** come first. On each load the browser asks NCMEC for +active alerts (`…/JSONDataServlet?action=amberAlert&missState=XX`, empty for +nationwide). If there are any, it shows a child from one at random, flagged +"AMBER Alert" with an "Alert issued" date, and fetches the alert's detail record +(`action=amberDetail&amberId=…`) for the photo and age. Posters are +`/poster/AMBER/{amberId}/{personId}`. No alerts, or a failed check, falls +through to the random open case below; an AMBER lookup failure must never +blank the board. `toAmberChild` drops any record that names an abductor or +companion: only the child is ever shown. + Data flow: the browser reads the launch settings (`?state=`), fetches page 1 of NCMEC's public search (`https://api.missingkids.org/missingkids/servlet/JSONDataServlet?action=publicSearch&search=new&subjToSearch=child[&missState=XX][&goToPage=N]`, @@ -67,7 +77,8 @@ bun run lint # biome lint --error-on-warnings `?v=__ASSET_VERSION__`, stamped at build. - `assets/static/js/cases.ts`: **pure, exported, unit-tested** helpers: settings parsing (`readSettings`, `normalizeState`), `searchUrl`, the response parser - (`parseSearch`/`toChild`/`parseTotalPages`), random selection + (`parseSearch`/`toChild`/`parseTotalPages`), the AMBER Alert parsers + (`amberUrl`/`parseAmber`/`toAmberChild`/`parseAmberDetail`), random selection (`pickRandomIndex`/`pickRandomPage`/`pickChild`), NCMEC date parsing (hand-rolled; `Date.parse` of `"Oct 13, 2026 12:00:00 AM"` is engine-specific), name/city casing (`tidyCase`), the poster-link UTM tags (`UTM`, `withUtm`), and display diff --git a/README.md b/README.md index 94ccdba..7be04d5 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,19 @@ An unknown `state` falls back to nationwide. ## Where the data comes from +**Active AMBER Alerts come first.** Each load checks NCMEC's active +[AMBER Alerts](https://www.missingkids.org/gethelpnow/amber) for the configured +state (or nationwide): + +``` +https://api.missingkids.org/missingkids/servlet/JSONDataServlet?action=amberAlert&missState=CA +``` + +While an alert is active, the board shows a child from it, flagged **AMBER +Alert**, with the alert's photo and poster. AMBER Alerts are usually resolved +within hours; with none active (the usual case) the app shows a random open +case as below. + The page calls the public JSON search behind [missingkids.org](https://www.missingkids.org/gethelpnow/search): diff --git a/assets/static/js/cases.ts b/assets/static/js/cases.ts index 632cdec..71675bf 100644 --- a/assets/static/js/cases.ts +++ b/assets/static/js/cases.ts @@ -83,6 +83,14 @@ export const US_STATES: Record = { VI: 'U.S. Virgin Islands' } +// Active AMBER Alerts live under this pseudo org prefix: their posters are +// `/poster/AMBER/{amberId}/{personId}`. +export const AMBER_PREFIX = 'AMBER' + +// Roles an AMBER Alert record can name besides the child. Such a record is +// never shown as "Have you seen this child?". +const NOT_A_CHILD = /abductor|companion|suspect/i + // Org prefixes whose records are unidentified remains, not missing children. export const UNIDENTIFIED_PREFIXES = new Set(['NCMU']) @@ -116,6 +124,8 @@ export interface MissingChild { photoUrl: string | null /** The public NCMEC poster for this case. */ posterUrl: string + /** True for a child in an active AMBER Alert. */ + amber: boolean } export interface Settings { @@ -152,13 +162,36 @@ export const searchUrl = (state: string | null, page = 1): string => { return `${API_BASE}${SEARCH_PATH}?${params.toString()}` } +/** NCMEC's active AMBER Alerts, optionally scoped to one state. */ +export const amberUrl = (state: string | null): string => { + const params = new URLSearchParams({ + action: 'amberAlert', + missState: state ?? '', + LanguageId: 'en_US' + }) + return `${API_BASE}${SEARCH_PATH}?${params.toString()}` +} + +/** The detail record for one AMBER Alert, which carries the photo. */ +export const amberDetailUrl = (amberId: string): string => { + const params = new URLSearchParams({ action: 'amberDetail', amberId, LanguageId: 'en_US' }) + return `${API_BASE}${SEARCH_PATH}?${params.toString()}` +} + /** - * Parses NCMEC's `"Oct 13, 2026 12:00:00 AM"` into `YYYY-MM-DD`. Done by hand + * Parses NCMEC's `"Oct 13, 2026 12:00:00 AM"` (or the AMBER list's `10/13/2026`) + * into `YYYY-MM-DD`. Done by hand * because `Date.parse` of that format is engine-specific, and the date is a * calendar date with no meaningful time or zone. */ export const parseMissingDate = (raw: unknown): string | null => { if (typeof raw !== 'string') return null + const numeric = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\b/.exec(raw.trim()) + if (numeric) { + const [month, day] = [Number(numeric[1]), Number(numeric[2])] + if (month < 1 || month > 12 || day < 1 || day > 31) return null + return `${numeric[3]}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` + } const m = /^([A-Za-z]{3})[a-z]*\.? +(\d{1,2}), +(\d{4})/.exec(raw.trim()) if (!m) return null const month = MONTHS_SHORT.findIndex((s) => s.toLowerCase() === m[1].toLowerCase()) @@ -234,7 +267,76 @@ export const toChild = (raw: unknown): MissingChild | null => { city: tidyCase(str(p.missingCity)), state: str(p.missingState).toUpperCase(), photoUrl: p.hasThumbnail !== false && image.startsWith('/') ? `${API_BASE}${image}` : null, - posterUrl: `${POSTER_BASE}/${encodeURIComponent(orgPrefix)}/${encodeURIComponent(caseNumber)}/${seq}` + posterUrl: `${POSTER_BASE}/${encodeURIComponent(orgPrefix)}/${encodeURIComponent(caseNumber)}/${seq}`, + amber: false + } +} + +/** A photo path from NCMEC, made absolute; null for anything off-origin. */ +const ncmecPhoto = (raw: unknown): string | null => { + const path = str(raw) + return path.startsWith('/') && !path.startsWith('//') ? `${API_BASE}${path}` : null +} + +/** + * Validates and normalizes one entry of the amberAlert response's `persons`. + * The field names follow NCMEC's own AMBER widget, which accepts either spelling + * of the id, name and place. The list carries no photo (see `amberPhotoUrl`). + */ +export const toAmberChild = (raw: unknown): MissingChild | null => { + if (!raw || typeof raw !== 'object') return null + const p = raw as Record + const id = str(p.amberId) || str(p.caseNumber) + const name = str(p.fullName) || [str(p.firstName), str(p.lastName)].filter(Boolean).join(' ') + if (!id || !name) return null + if (p.isChild === false) return null + for (const role of [p.personType, p.role, p.relationship, p.subjectType]) { + if (typeof role === 'string' && NOT_A_CHILD.test(role)) return null + } + const seq = typeof p.personId === 'number' || typeof p.personId === 'string' ? p.personId : 1 + // `missingFrom` is "City ST"; otherwise separate city/state fields. + const from = /^(.+?),?\s+([A-Za-z]{2})$/.exec(str(p.missingFrom)) + return { + caseNumber: id, + name: tidyCase(name), + age: typeof p.age === 'number' && p.age > 0 ? p.age : null, + missingDate: + parseMissingDate(p.alertDate) ?? parseMissingDate(p.missingDate) ?? parseMissingDate(p.date), + city: tidyCase(from ? from[1] : str(p.city) || str(p.missingCity)), + state: (from ? from[2] : str(p.state) || str(p.missingState)).toUpperCase(), + photoUrl: ncmecPhoto(p.imageUrl), + posterUrl: `${POSTER_BASE}/${AMBER_PREFIX}/${encodeURIComponent(id)}/${encodeURIComponent(String(seq))}`, + amber: true + } +} + +/** Parses an amberAlert response into the children in active alerts. */ +export const parseAmber = (data: unknown): MissingChild[] => { + if (!data || typeof data !== 'object') return [] + const persons = (data as { persons?: unknown }).persons + if (!Array.isArray(persons)) return [] + const seen = new Set() + const out: MissingChild[] = [] + for (const raw of persons) { + const child = toAmberChild(raw) + if (!child || seen.has(child.posterUrl)) continue + seen.add(child.posterUrl) + out.push(child) + } + return out +} + +/** + * The photo and age from an amberDetail response's `childBean`, which the + * alert list lacks. Anything missing or malformed comes back null. + */ +export const parseAmberDetail = (data: unknown): Pick => { + const bean = data && typeof data === 'object' ? (data as { childBean?: unknown }).childBean : null + if (!bean || typeof bean !== 'object') return { photoUrl: null, age: null } + const b = bean as Record + return { + photoUrl: ncmecPhoto(b.imageUrl), + age: typeof b.age === 'number' && b.age > 0 ? b.age : null } } diff --git a/assets/static/js/main.ts b/assets/static/js/main.ts index 6b6a2f4..9bac535 100644 --- a/assets/static/js/main.ts +++ b/assets/static/js/main.ts @@ -8,7 +8,11 @@ // child. Picking at random across every page, not just the newest 20, spreads // the exposure over all open cases. // -// Flow: read the launch settings → fetch page 1 (which also reports how many +// An active AMBER Alert comes first: while NCMEC lists one for this area, every +// load shows a child from it (flagged "AMBER Alert"). Only when there is none, +// or the lookup fails, does the board fall back to a random open case. +// +// Flow: read the launch settings → check for active AMBER Alerts → fetch page 1 (which also reports how many // pages there are) → fetch one random page → pick a child at random. If the // random page fails, fall back to a child from page 1. If nothing loads at all, // or the filter has no open cases, the standing appeal stays up. There is @@ -25,14 +29,19 @@ import qrcode from 'qrcode-generator' import { type MissingChild, US_STATES, + amberDetailUrl, + amberUrl, daysSince, displayUrl, formatDate, formatDuration, formatPlace, + parseAmber, + parseAmberDetail, parseSearch, parseTotalPages, pickChild, + pickRandomIndex, pickRandomPage, readSettings, searchUrl, @@ -132,6 +141,11 @@ const fitCard = (): void => { const render = (child: MissingChild): void => { const today = new Date() + if (child.amber) { + document.documentElement.dataset.alert = 'amber' + text('mc-flag', 'AMBER Alert') + text('mc-date-label', 'Alert issued') + } text('mc-name', child.name) text('mc-age', child.age === null ? 'Unknown' : String(child.age)) text('mc-place', formatPlace(child) || 'Unknown') @@ -155,11 +169,11 @@ const render = (child: MissingChild): void => { if (document.fonts?.ready) document.fonts.ready.then(fitCard) } -const fetchPage = async (page: number): Promise => { +const fetchJson = async (url: string): Promise => { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) try { - const res = await fetch(searchUrl(settings.state, page), { + const res = await fetch(url, { credentials: 'omit', signal: controller.signal }) @@ -170,6 +184,31 @@ const fetchPage = async (page: number): Promise => { } } +const fetchPage = (page: number): Promise => fetchJson(searchUrl(settings.state, page)) + +// A child from an active AMBER Alert for this area, or null when there is none +// or NCMEC can't be reached (the random-case path then runs as usual). The list +// has no photo or age, so the alert's detail record is fetched for them; without +// it the initials card stands in. +const loadAmber = async (): Promise => { + let alerts: MissingChild[] + try { + alerts = parseAmber(await fetchJson(amberUrl(settings.state))) + } catch (error) { + console.error('Missing children: AMBER Alert check failed —', error) + return null + } + if (alerts.length === 0) return null + const child = alerts[pickRandomIndex(alerts.length)] + try { + const detail = parseAmberDetail(await fetchJson(amberDetailUrl(child.caseNumber))) + return { ...child, photoUrl: child.photoUrl ?? detail.photoUrl, age: child.age ?? detail.age } + } catch (error) { + console.error('Missing children: AMBER Alert photo failed —', error) + return child + } +} + // The page count from the last load, per filter. Remembering it lets the next // load fetch one random page straight away instead of page 1 first, halving // the time to first content. It is only a number, never a case: every case @@ -248,7 +287,8 @@ const init = (): void => { fitCard() }) }) - loadChild() + loadAmber() + .then((alert) => alert ?? loadChild()) .then((child) => { if (child) render(child) else showAppeal() // no open cases for this filter diff --git a/index.html b/index.html index 08e865f..df0a955 100644 --- a/index.html +++ b/index.html @@ -33,7 +33,7 @@

- Missing + Missing United States

@@ -56,7 +56,7 @@

-
Missing since
+
Missing since
diff --git a/test/cases.test.ts b/test/cases.test.ts index 28045e0..af9e9e1 100644 --- a/test/cases.test.ts +++ b/test/cases.test.ts @@ -1,11 +1,15 @@ import { describe, expect, test } from 'bun:test' import { + amberDetailUrl, + amberUrl, daysSince, displayUrl, formatDate, formatDuration, formatPlace, normalizeState, + parseAmber, + parseAmberDetail, parseMissingDate, parseSearch, parseTotalPages, @@ -15,6 +19,7 @@ import { readSettings, searchUrl, tidyCase, + toAmberChild, toChild, withUtm } from '../assets/static/js/cases' @@ -133,6 +138,12 @@ describe('dates', () => { expect(parseMissingDate('Sep 30, 2001')).toBe('2001-09-30') }) + test('parses the AMBER list numeric dates', () => { + expect(parseMissingDate('9/11/2006')).toBe('2006-09-11') + expect(parseMissingDate('10/20/2026 3:15:00 PM')).toBe('2026-10-20') + expect(parseMissingDate('13/1/2026')).toBeNull() + }) + test('rejects anything else', () => { expect(parseMissingDate('2026-10-13')).toBeNull() expect(parseMissingDate('Foo 1, 2020')).toBeNull() @@ -181,7 +192,8 @@ describe('toChild', () => { city: 'Springfield', state: 'IL', photoUrl: 'https://api.missingkids.org/photographs/NCMC1234567c1.jpg', - posterUrl: 'https://www.missingkids.org/poster/NCMC/1234567/1' + posterUrl: 'https://www.missingkids.org/poster/NCMC/1234567/1', + amber: false }) }) @@ -265,3 +277,102 @@ describe('display helpers', () => { ) }) }) + +// Shaped like one entry of NCMEC's amberAlert `persons` array, per the field +// names its own AMBER widget reads. Fictional. +const alertPerson = (overrides: Record = {}) => ({ + amberId: 'AA1234', + personId: 2, + firstName: 'JANE', + lastName: 'EXAMPLE', + city: 'SPRINGFIELD', + state: 'il', + alertDate: 'Oct 20, 2026 3:15:00 PM', + issuedFor: 'Illinois', + ...overrides +}) + +describe('AMBER Alerts', () => { + test('amberUrl asks for active alerts, optionally for one state', () => { + const all = new URL(amberUrl(null)) + expect(all.origin).toBe('https://api.missingkids.org') + expect(all.searchParams.get('action')).toBe('amberAlert') + expect(all.searchParams.get('missState')).toBe('') + expect(new URL(amberUrl('TX')).searchParams.get('missState')).toBe('TX') + const detail = new URL(amberDetailUrl('AA1234')) + expect(detail.searchParams.get('action')).toBe('amberDetail') + expect(detail.searchParams.get('amberId')).toBe('AA1234') + }) + + test('normalizes an alert record', () => { + expect(toAmberChild(alertPerson())).toEqual({ + caseNumber: 'AA1234', + name: 'Jane Example', + age: null, + missingDate: '2026-10-20', + city: 'Springfield', + state: 'IL', + photoUrl: null, + posterUrl: 'https://www.missingkids.org/poster/AMBER/AA1234/2', + amber: true + }) + }) + + test('accepts the alternate spellings', () => { + const child = toAmberChild( + alertPerson({ + amberId: '', + caseNumber: 'AA9', + firstName: '', + lastName: '', + fullName: 'JANE Q EXAMPLE', + missingFrom: 'SPRINGFIELD, IL', + alertDate: '', + missingDate: '10/20/2026' + }) + ) + expect(child?.caseNumber).toBe('AA9') + expect(child?.name).toBe('Jane Q Example') + expect(child?.city).toBe('Springfield') + expect(child?.state).toBe('IL') + expect(child?.missingDate).toBe('2026-10-20') + }) + + test('never shows an abductor or companion as the child', () => { + expect(toAmberChild(alertPerson({ personType: 'Abductor' }))).toBeNull() + expect(toAmberChild(alertPerson({ role: "Abductor's Companion" }))).toBeNull() + expect(toAmberChild(alertPerson({ isChild: false }))).toBeNull() + expect(toAmberChild(alertPerson({ personType: 'child' }))).not.toBeNull() + }) + + test('rejects malformed records', () => { + expect(toAmberChild(null)).toBeNull() + expect(toAmberChild(alertPerson({ amberId: '' }))).toBeNull() + expect(toAmberChild(alertPerson({ firstName: '', lastName: '' }))).toBeNull() + }) + + test('parseAmber handles the empty response NCMEC sends with no alerts', () => { + expect( + parseAmber({ status: 'success', type: 'amberAlert', alertCount: 0, persons: [] }) + ).toEqual([]) + expect(parseAmber(null)).toEqual([]) + const two = parseAmber({ + persons: [alertPerson(), alertPerson(), alertPerson({ personId: 3 })] + }) + expect(two.map((c) => c.posterUrl)).toEqual([ + 'https://www.missingkids.org/poster/AMBER/AA1234/2', + 'https://www.missingkids.org/poster/AMBER/AA1234/3' + ]) + }) + + test('parseAmberDetail reads the photo (on NCMEC only) and age', () => { + expect( + parseAmberDetail({ childBean: { imageUrl: '/photographs/AMBERAA1234c2.jpg', age: 9 } }) + ).toEqual({ photoUrl: 'https://api.missingkids.org/photographs/AMBERAA1234c2.jpg', age: 9 }) + const photo = (imageUrl: string) => parseAmberDetail({ childBean: { imageUrl } }).photoUrl + expect(photo('https://example.com/x.jpg')).toBeNull() + expect(photo('//example.com/x.jpg')).toBeNull() + expect(parseAmberDetail({})).toEqual({ photoUrl: null, age: null }) + expect(parseAmberDetail(null)).toEqual({ photoUrl: null, age: null }) + }) +})