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 }) + }) +})