open-primitive-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/mcp.js +193 -0
- package/package.json +22 -0
- package/sources/alerts.js +190 -0
- package/sources/ask.js +367 -0
- package/sources/cars.js +57 -0
- package/sources/compare.js +100 -0
- package/sources/demographics.js +74 -0
- package/sources/drugs.js +74 -0
- package/sources/flights.js +125 -0
- package/sources/food.js +74 -0
- package/sources/health.js +102 -0
- package/sources/hospitals.js +115 -0
- package/sources/jobs.js +77 -0
- package/sources/location.js +54 -0
- package/sources/nutrition.js +91 -0
- package/sources/products.js +63 -0
- package/sources/safety.js +76 -0
- package/sources/sec.js +118 -0
- package/sources/water.js +101 -0
- package/sources/weather.js +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Open Primitive Protocol (OPP)
|
|
2
|
+
|
|
3
|
+
**The data layer of the agent internet.**
|
|
4
|
+
|
|
5
|
+
OPP defines how data providers make their data agent-consumable, verifiable, and discoverable. Three components: provider manifest, response envelope, query interface.
|
|
6
|
+
|
|
7
|
+
## The Problem
|
|
8
|
+
|
|
9
|
+
Agents get data naked. No provenance. No freshness guarantee. No confidence score. No way to verify the source. The agent internet is missing its data envelope.
|
|
10
|
+
|
|
11
|
+
## The Protocol
|
|
12
|
+
|
|
13
|
+
| Component | What it does |
|
|
14
|
+
|-----------|-------------|
|
|
15
|
+
| **Manifest** (`/.well-known/opp.json`) | Declares what data a provider serves, how fresh it is, how to verify it |
|
|
16
|
+
| **Response Envelope** | Every response carries domain, source, freshness, confidence, citation, Ed25519 signature |
|
|
17
|
+
| **Query Interface** | Standardized HTTP GET endpoints with predictable parameters |
|
|
18
|
+
|
|
19
|
+
## Conformance Levels
|
|
20
|
+
|
|
21
|
+
- **Level 1 (Basic):** Manifest + envelope with domain/source/freshness
|
|
22
|
+
- **Level 2 (Cited):** Level 1 + confidence scores + citations
|
|
23
|
+
- **Level 3 (Verified):** Level 2 + Ed25519 cryptographic proof
|
|
24
|
+
|
|
25
|
+
## Reference Implementation
|
|
26
|
+
|
|
27
|
+
Live at [api.openprimitive.com](https://api.openprimitive.com). 16 US federal data domains across 11 agencies. Level 3 compliant — every response is signed.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
curl https://api.openprimitive.com/v1/drugs?name=aspirin
|
|
31
|
+
# Returns OPP envelope with proof.type: "DataIntegrityProof"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
**Use data from an OPP provider:**
|
|
37
|
+
```javascript
|
|
38
|
+
const res = await fetch('https://api.openprimitive.com/v1/drugs?name=aspirin');
|
|
39
|
+
const data = await res.json();
|
|
40
|
+
console.log(data.citations.statement);
|
|
41
|
+
// "According to FDA FAERS, aspirin has 601,477 reported adverse events"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Implement OPP for your own data:**
|
|
45
|
+
```javascript
|
|
46
|
+
// 1. Create /.well-known/opp.json (see spec)
|
|
47
|
+
// 2. Wrap responses in the OPP envelope
|
|
48
|
+
// 3. Optionally sign with Ed25519
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Specification
|
|
52
|
+
|
|
53
|
+
Full spec: [openprimitive.com/protocol.html](https://openprimitive.com/protocol.html)
|
|
54
|
+
|
|
55
|
+
Detailed spec: [api.openprimitive.com/spec.html](https://api.openprimitive.com/spec.html)
|
|
56
|
+
|
|
57
|
+
## SDK
|
|
58
|
+
|
|
59
|
+
- `sdk/opp-client.js` — Client library for consuming OPP providers
|
|
60
|
+
- `sdk/opp-provider.js` — Helper for implementing OPP
|
|
61
|
+
- `sdk/opp-validator.js` — Validate OPP conformance
|
|
62
|
+
|
|
63
|
+
## MCP Server
|
|
64
|
+
|
|
65
|
+
13 tools for Claude, Cursor, and MCP-compatible agents:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx open-primitive-mcp
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Links
|
|
72
|
+
|
|
73
|
+
- [Protocol Spec](https://openprimitive.com/protocol.html)
|
|
74
|
+
- [API Reference](https://api.openprimitive.com)
|
|
75
|
+
- [Manifesto](https://openprimitive.com/manifesto.html)
|
|
76
|
+
- [OpenAPI Spec](https://api.openprimitive.com/openapi.json)
|
|
77
|
+
- [OPP Manifest](https://api.openprimitive.com/.well-known/opp.json)
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
package/mcp.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
3
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
4
|
+
const { z } = require('zod');
|
|
5
|
+
|
|
6
|
+
const flights = require('./sources/flights');
|
|
7
|
+
const cars = require('./sources/cars');
|
|
8
|
+
const food = require('./sources/food');
|
|
9
|
+
const water = require('./sources/water');
|
|
10
|
+
const drugs = require('./sources/drugs');
|
|
11
|
+
const hospitals = require('./sources/hospitals');
|
|
12
|
+
const health = require('./sources/health');
|
|
13
|
+
const nutrition = require('./sources/nutrition');
|
|
14
|
+
const jobs = require('./sources/jobs');
|
|
15
|
+
const demographics = require('./sources/demographics');
|
|
16
|
+
const products = require('./sources/products');
|
|
17
|
+
const sec = require('./sources/sec');
|
|
18
|
+
const safety = require('./sources/safety');
|
|
19
|
+
|
|
20
|
+
const server = new McpServer({
|
|
21
|
+
name: 'open-primitive',
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// ─── FLIGHTS ───
|
|
26
|
+
server.registerTool('get-flights', {
|
|
27
|
+
title: 'Get Flight Status',
|
|
28
|
+
description: 'Get live delay and weather data for 8 major US airlines. Source: FAA NAS + Open-Meteo.',
|
|
29
|
+
inputSchema: z.object({
|
|
30
|
+
airline: z.string().optional().describe('IATA code (DL, UA, AA, WN, AS, B6, G4, F9). Omit for all airlines.'),
|
|
31
|
+
}),
|
|
32
|
+
}, async ({ airline }) => {
|
|
33
|
+
const data = airline ? await flights.getAirline(airline) : await flights.getAirlines();
|
|
34
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ─── CARS ───
|
|
38
|
+
server.registerTool('get-car-safety', {
|
|
39
|
+
title: 'Get Car Safety',
|
|
40
|
+
description: 'Get NHTSA crash safety ratings and recalls for a vehicle. Source: NHTSA.',
|
|
41
|
+
inputSchema: z.object({
|
|
42
|
+
year: z.string().describe('Model year (e.g. "2024")'),
|
|
43
|
+
make: z.string().describe('Manufacturer (e.g. "Toyota")'),
|
|
44
|
+
model: z.string().describe('Model name (e.g. "Camry")'),
|
|
45
|
+
}),
|
|
46
|
+
}, async ({ year, make, model }) => {
|
|
47
|
+
const data = await cars.getSafety(year, make, model);
|
|
48
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ─── FOOD ───
|
|
52
|
+
server.registerTool('get-food-recalls', {
|
|
53
|
+
title: 'Get Food Recalls',
|
|
54
|
+
description: 'Get active FDA food recalls or search by product/brand. Source: FDA Enforcement.',
|
|
55
|
+
inputSchema: z.object({
|
|
56
|
+
query: z.string().optional().describe('Search term (product or brand). Omit for recent recalls.'),
|
|
57
|
+
}),
|
|
58
|
+
}, async ({ query }) => {
|
|
59
|
+
const data = query ? await food.search(query) : await food.getRecent();
|
|
60
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ─── WATER ───
|
|
64
|
+
server.registerTool('get-water-safety', {
|
|
65
|
+
title: 'Get Water Safety',
|
|
66
|
+
description: 'Get drinking water system data and violations. Source: EPA SDWIS.',
|
|
67
|
+
inputSchema: z.object({
|
|
68
|
+
zip: z.string().optional().describe('5-digit ZIP code to find water systems'),
|
|
69
|
+
pwsid: z.string().optional().describe('Public Water System ID for detailed violations'),
|
|
70
|
+
}),
|
|
71
|
+
}, async ({ zip, pwsid }) => {
|
|
72
|
+
const data = pwsid ? await water.getSystem(pwsid) : await water.searchByZip(zip || '');
|
|
73
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ─── DRUGS ───
|
|
77
|
+
server.registerTool('get-drug-safety', {
|
|
78
|
+
title: 'Get Drug Safety',
|
|
79
|
+
description: 'Get FDA adverse event reports, top reactions, and label warnings for a drug. Source: FDA FAERS.',
|
|
80
|
+
inputSchema: z.object({
|
|
81
|
+
name: z.string().describe('Drug name (brand or generic, e.g. "ibuprofen")'),
|
|
82
|
+
}),
|
|
83
|
+
}, async ({ name }) => {
|
|
84
|
+
const data = await drugs.getDrug(name);
|
|
85
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ─── HOSPITALS ───
|
|
89
|
+
server.registerTool('get-hospital-quality', {
|
|
90
|
+
title: 'Get Hospital Quality',
|
|
91
|
+
description: 'Search hospitals or get detailed quality ratings. Source: CMS Care Compare.',
|
|
92
|
+
inputSchema: z.object({
|
|
93
|
+
query: z.string().optional().describe('Hospital name or ZIP code to search'),
|
|
94
|
+
providerId: z.string().optional().describe('CMS Provider ID for detailed quality data'),
|
|
95
|
+
}),
|
|
96
|
+
}, async ({ query, providerId }) => {
|
|
97
|
+
const data = providerId ? await hospitals.getHospital(providerId) : await hospitals.searchHospitals(query || '');
|
|
98
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ─── HEALTH ───
|
|
102
|
+
server.registerTool('get-health-evidence', {
|
|
103
|
+
title: 'Get Health Evidence',
|
|
104
|
+
description: 'Search PubMed for research evidence on supplements or health claims. Source: PubMed/MEDLINE.',
|
|
105
|
+
inputSchema: z.object({
|
|
106
|
+
query: z.string().describe('Supplement name or health claim (e.g. "vitamin d", "turmeric inflammation")'),
|
|
107
|
+
}),
|
|
108
|
+
}, async ({ query }) => {
|
|
109
|
+
const data = await health.searchHealth(query);
|
|
110
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ─── NUTRITION ───
|
|
114
|
+
server.registerTool('get-nutrition', {
|
|
115
|
+
title: 'Get Nutrition Data',
|
|
116
|
+
description: 'Search USDA FoodData Central for nutrition facts or get details by FDC ID. Source: USDA.',
|
|
117
|
+
inputSchema: z.object({
|
|
118
|
+
query: z.string().optional().describe('Food search term (e.g. "banana", "cheddar cheese"). Omit if using fdcId.'),
|
|
119
|
+
fdcId: z.string().optional().describe('FDC ID for a specific food item. Omit if using query.'),
|
|
120
|
+
}),
|
|
121
|
+
}, async ({ query, fdcId }) => {
|
|
122
|
+
const data = fdcId ? await nutrition.getFood(fdcId) : await nutrition.searchFood(query || '');
|
|
123
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ─── JOBS ───
|
|
127
|
+
server.registerTool('get-jobs', {
|
|
128
|
+
title: 'Get Jobs Data',
|
|
129
|
+
description: 'Get unemployment rate or other BLS time series data. Source: Bureau of Labor Statistics.',
|
|
130
|
+
inputSchema: z.object({
|
|
131
|
+
seriesId: z.string().optional().describe('BLS series ID (e.g. "LNS14000000" for unemployment rate). Omit for default unemployment data.'),
|
|
132
|
+
}),
|
|
133
|
+
}, async ({ seriesId }) => {
|
|
134
|
+
const data = seriesId ? await jobs.getSeriesData(seriesId) : await jobs.getUnemployment();
|
|
135
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ─── DEMOGRAPHICS ───
|
|
139
|
+
server.registerTool('get-demographics', {
|
|
140
|
+
title: 'Get Demographics',
|
|
141
|
+
description: 'Get Census demographics for a ZIP code: population, income, poverty, education, housing. Source: US Census ACS.',
|
|
142
|
+
inputSchema: z.object({
|
|
143
|
+
zip: z.string().describe('5-digit ZIP code'),
|
|
144
|
+
}),
|
|
145
|
+
}, async ({ zip }) => {
|
|
146
|
+
const data = await demographics.getByZip(zip);
|
|
147
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ─── PRODUCT RECALLS ───
|
|
151
|
+
server.registerTool('get-product-recalls', {
|
|
152
|
+
title: 'Get Product Recalls',
|
|
153
|
+
description: 'Get recent CPSC consumer product recalls or search by keyword. Source: SaferProducts.gov.',
|
|
154
|
+
inputSchema: z.object({
|
|
155
|
+
query: z.string().optional().describe('Search term (product type or brand). Omit for recent recalls.'),
|
|
156
|
+
}),
|
|
157
|
+
}, async ({ query }) => {
|
|
158
|
+
const data = query ? await products.search(query) : await products.getRecent();
|
|
159
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ─── SEC FILINGS ───
|
|
163
|
+
server.registerTool('get-sec-filings', {
|
|
164
|
+
title: 'Get SEC Filings',
|
|
165
|
+
description: 'Search SEC EDGAR for company filings or get structured financial facts by CIK. Source: SEC EDGAR.',
|
|
166
|
+
inputSchema: z.object({
|
|
167
|
+
query: z.string().optional().describe('Company name to search (e.g. "Apple"). Omit if using cik.'),
|
|
168
|
+
cik: z.string().optional().describe('SEC CIK number for detailed company facts. Omit if using query.'),
|
|
169
|
+
}),
|
|
170
|
+
}, async ({ query, cik }) => {
|
|
171
|
+
const data = cik ? await sec.getCompanyFacts(cik) : await sec.searchCompany(query || '');
|
|
172
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─── SAFETY PROFILE ───
|
|
176
|
+
server.registerTool('get-safety-profile', {
|
|
177
|
+
title: 'Get Safety Profile',
|
|
178
|
+
description: 'Get a cross-domain safety composite for a ZIP code combining water quality and hospital ratings. Source: EPA + CMS.',
|
|
179
|
+
inputSchema: z.object({
|
|
180
|
+
zip: z.string().describe('5-digit ZIP code'),
|
|
181
|
+
}),
|
|
182
|
+
}, async ({ zip }) => {
|
|
183
|
+
const data = await safety.getSafetyProfile(zip);
|
|
184
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Start
|
|
188
|
+
async function main() {
|
|
189
|
+
const transport = new StdioServerTransport();
|
|
190
|
+
await server.connect(transport);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
main().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "open-primitive-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for 13 federal data domains — flights, cars, food, water, drugs, hospitals, health, nutrition, jobs, demographics, products, SEC, safety",
|
|
5
|
+
"main": "mcp.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"open-primitive-mcp": "./mcp.js"
|
|
8
|
+
},
|
|
9
|
+
"files": ["mcp.js", "sources/", "package.json"],
|
|
10
|
+
"keywords": ["mcp", "model-context-protocol", "federal-data", "government", "ai-agents", "open-primitive"],
|
|
11
|
+
"author": "David Hamilton <davehamiltonj@gmail.com>",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/writesdavid/open-primitive-api"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"node-fetch": "^2.7.0",
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
20
|
+
"zod": "^3.24.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
const { Redis } = require('@upstash/redis');
|
|
2
|
+
const food = require('./food');
|
|
3
|
+
const products = require('./products');
|
|
4
|
+
|
|
5
|
+
const ALERT_KEY = 'alerts:feed';
|
|
6
|
+
const SNAPSHOT_KEY = 'alerts:snapshot';
|
|
7
|
+
const MAX_ALERTS = 50;
|
|
8
|
+
|
|
9
|
+
let redis = null;
|
|
10
|
+
|
|
11
|
+
function getRedis() {
|
|
12
|
+
if (!redis && process.env.UPSTASH_REDIS_REST_URL) {
|
|
13
|
+
redis = new Redis({
|
|
14
|
+
url: process.env.UPSTASH_REDIS_REST_URL,
|
|
15
|
+
token: process.env.UPSTASH_REDIS_REST_TOKEN,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return redis;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── ALERT RULES ───
|
|
22
|
+
// Each rule: { domain, match(item) => alert | null }
|
|
23
|
+
|
|
24
|
+
const FOOD_RULES = {
|
|
25
|
+
domain: 'food',
|
|
26
|
+
extract(recalls) {
|
|
27
|
+
return recalls
|
|
28
|
+
.filter(r => r.classification === 'Class I')
|
|
29
|
+
.map(r => ({
|
|
30
|
+
domain: 'food',
|
|
31
|
+
event: 'class_i_recall',
|
|
32
|
+
severity: 'critical',
|
|
33
|
+
title: `Class I Recall: ${(r.firm || 'Unknown firm').slice(0, 80)}`,
|
|
34
|
+
summary: (r.reason || '').slice(0, 300),
|
|
35
|
+
data: {
|
|
36
|
+
product: r.product,
|
|
37
|
+
firm: r.firm,
|
|
38
|
+
recallNumber: r.recallNumber,
|
|
39
|
+
date: r.date,
|
|
40
|
+
distribution: r.distribution,
|
|
41
|
+
},
|
|
42
|
+
endpoint: '/v1/food',
|
|
43
|
+
timestamp: new Date().toISOString(),
|
|
44
|
+
}));
|
|
45
|
+
},
|
|
46
|
+
fingerprint(item) {
|
|
47
|
+
return `food:${item.data.recallNumber || item.title}`;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const PRODUCTS_RULES = {
|
|
52
|
+
domain: 'products',
|
|
53
|
+
extract(recalls) {
|
|
54
|
+
const childKeywords = /child|infant|baby|toddler|kid|crib|stroller|toy|pacifier|nursery/i;
|
|
55
|
+
return recalls
|
|
56
|
+
.filter(r => {
|
|
57
|
+
const text = `${r.title} ${r.description} ${(r.products || []).map(p => p.name).join(' ')}`;
|
|
58
|
+
return childKeywords.test(text);
|
|
59
|
+
})
|
|
60
|
+
.map(r => ({
|
|
61
|
+
domain: 'products',
|
|
62
|
+
event: 'children_product_recall',
|
|
63
|
+
severity: 'critical',
|
|
64
|
+
title: `Children's Product Recall: ${(r.title || '').slice(0, 80)}`,
|
|
65
|
+
summary: (r.description || '').slice(0, 300),
|
|
66
|
+
data: {
|
|
67
|
+
recallId: r.recallId,
|
|
68
|
+
products: r.products,
|
|
69
|
+
hazards: r.hazards,
|
|
70
|
+
manufacturers: r.manufacturers,
|
|
71
|
+
recallDate: r.recallDate,
|
|
72
|
+
url: r.url,
|
|
73
|
+
},
|
|
74
|
+
endpoint: '/v1/products',
|
|
75
|
+
timestamp: new Date().toISOString(),
|
|
76
|
+
}));
|
|
77
|
+
},
|
|
78
|
+
fingerprint(item) {
|
|
79
|
+
return `products:${item.data.recallId || item.title}`;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ─── CORE ───
|
|
84
|
+
|
|
85
|
+
async function getSnapshot() {
|
|
86
|
+
const r = getRedis();
|
|
87
|
+
if (!r) return {};
|
|
88
|
+
const raw = await r.get(SNAPSHOT_KEY);
|
|
89
|
+
if (!raw) return {};
|
|
90
|
+
return typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function saveSnapshot(snapshot) {
|
|
94
|
+
const r = getRedis();
|
|
95
|
+
if (!r) return;
|
|
96
|
+
await r.set(SNAPSHOT_KEY, JSON.stringify(snapshot));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function pushAlerts(newAlerts) {
|
|
100
|
+
const r = getRedis();
|
|
101
|
+
if (!r) return;
|
|
102
|
+
const raw = await r.get(ALERT_KEY);
|
|
103
|
+
let existing = [];
|
|
104
|
+
if (raw) {
|
|
105
|
+
existing = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
106
|
+
}
|
|
107
|
+
const merged = [...newAlerts, ...existing].slice(0, MAX_ALERTS);
|
|
108
|
+
await r.set(ALERT_KEY, JSON.stringify(merged));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function checkForAlerts() {
|
|
112
|
+
const snapshot = await getSnapshot();
|
|
113
|
+
const newAlerts = [];
|
|
114
|
+
|
|
115
|
+
// Food: Class I recalls
|
|
116
|
+
try {
|
|
117
|
+
const foodData = await food.getRecent();
|
|
118
|
+
if (foodData.recalls && !foodData.error) {
|
|
119
|
+
const candidates = FOOD_RULES.extract(foodData.recalls);
|
|
120
|
+
for (const alert of candidates) {
|
|
121
|
+
const fp = FOOD_RULES.fingerprint(alert);
|
|
122
|
+
if (!snapshot[fp]) {
|
|
123
|
+
newAlerts.push(alert);
|
|
124
|
+
snapshot[fp] = Date.now();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.error('Alert check failed for food:', err.message);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Products: children's recalls
|
|
133
|
+
try {
|
|
134
|
+
const productsData = await products.getRecent();
|
|
135
|
+
if (productsData.recalls && !productsData.error) {
|
|
136
|
+
const candidates = PRODUCTS_RULES.extract(productsData.recalls);
|
|
137
|
+
for (const alert of candidates) {
|
|
138
|
+
const fp = PRODUCTS_RULES.fingerprint(alert);
|
|
139
|
+
if (!snapshot[fp]) {
|
|
140
|
+
newAlerts.push(alert);
|
|
141
|
+
snapshot[fp] = Date.now();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error('Alert check failed for products:', err.message);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Prune snapshot entries older than 90 days
|
|
150
|
+
const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
|
|
151
|
+
for (const key of Object.keys(snapshot)) {
|
|
152
|
+
if (snapshot[key] < cutoff) delete snapshot[key];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (newAlerts.length > 0) {
|
|
156
|
+
await pushAlerts(newAlerts);
|
|
157
|
+
}
|
|
158
|
+
await saveSnapshot(snapshot);
|
|
159
|
+
|
|
160
|
+
return newAlerts;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function getAlertFeed() {
|
|
164
|
+
const r = getRedis();
|
|
165
|
+
|
|
166
|
+
// No Redis: run a live check and return what we find
|
|
167
|
+
if (!r) {
|
|
168
|
+
const live = await checkForAlerts();
|
|
169
|
+
return {
|
|
170
|
+
alerts: live.slice(0, MAX_ALERTS),
|
|
171
|
+
count: live.length,
|
|
172
|
+
lastUpdated: new Date().toISOString(),
|
|
173
|
+
note: 'Live check — no persistent store configured',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const raw = await r.get(ALERT_KEY);
|
|
178
|
+
let alerts = [];
|
|
179
|
+
if (raw) {
|
|
180
|
+
alerts = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
alerts,
|
|
185
|
+
count: alerts.length,
|
|
186
|
+
lastUpdated: alerts.length > 0 ? alerts[0].timestamp : null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { checkForAlerts, getAlertFeed };
|