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/sources/ask.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
const water = require('./water');
|
|
2
|
+
const hospitals = require('./hospitals');
|
|
3
|
+
const drugs = require('./drugs');
|
|
4
|
+
const food = require('./food');
|
|
5
|
+
const cars = require('./cars');
|
|
6
|
+
const flights = require('./flights');
|
|
7
|
+
const health = require('./health');
|
|
8
|
+
const nutrition = require('./nutrition');
|
|
9
|
+
const jobs = require('./jobs');
|
|
10
|
+
const demographics = require('./demographics');
|
|
11
|
+
const products = require('./products');
|
|
12
|
+
const sec = require('./sec');
|
|
13
|
+
const weather = require('./weather');
|
|
14
|
+
const safety = require('./safety');
|
|
15
|
+
const compare = require('./compare');
|
|
16
|
+
|
|
17
|
+
const DOMAIN_KEYWORDS = {
|
|
18
|
+
water: ['water', 'tap', 'drinking', 'contamination', 'epa', 'violation', 'lead in water', 'pfas'],
|
|
19
|
+
hospitals: ['hospital', 'doctor', 'mortality', 'readmission', 'surgery', 'emergency room', 'er '],
|
|
20
|
+
drugs: ['drug', 'medication', 'side effect', 'adverse', 'prescription', 'pharma'],
|
|
21
|
+
food: ['food', 'recall', 'fda', 'contamination', 'allergen', 'foodborne', 'salmonella', 'listeria', 'e. coli'],
|
|
22
|
+
cars: ['car', 'vehicle', 'crash', 'safety rating', 'nhtsa', 'auto recall', 'automobile'],
|
|
23
|
+
flights: ['flight', 'airline', 'delay', 'airport', 'faa', 'flying', 'plane'],
|
|
24
|
+
health: ['health', 'supplement', 'vitamin', 'evidence', 'study', 'clinical trial', 'does .* work'],
|
|
25
|
+
nutrition: ['nutrition', 'calories', 'protein', 'fat', 'carbs', 'diet', 'fiber', 'sodium'],
|
|
26
|
+
jobs: ['job', 'unemployment', 'wage', 'employment', 'salary', 'labor', 'payroll'],
|
|
27
|
+
demographics: ['population', 'income', 'poverty', 'demographics', 'census', 'median income'],
|
|
28
|
+
products: ['product', 'cpsc', 'consumer', 'toy', 'appliance', 'product recall'],
|
|
29
|
+
sec: ['sec', 'filing', 'stock', '10-k', 'earnings', 'quarterly report', 'annual report'],
|
|
30
|
+
weather: ['weather', 'forecast', 'storm', 'temperature', 'alert', 'rain', 'snow', 'wind'],
|
|
31
|
+
safety: ['safe', 'dangerous', 'risk'],
|
|
32
|
+
compare: ['compare', ' vs ', 'versus', 'better', 'worse', 'which is'],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function detectDomains(q) {
|
|
36
|
+
const lower = q.toLowerCase();
|
|
37
|
+
const matched = [];
|
|
38
|
+
|
|
39
|
+
for (const [domain, keywords] of Object.entries(DOMAIN_KEYWORDS)) {
|
|
40
|
+
for (const kw of keywords) {
|
|
41
|
+
if (kw.includes('.') || kw.includes('*')) {
|
|
42
|
+
if (new RegExp(kw, 'i').test(lower)) { matched.push(domain); break; }
|
|
43
|
+
} else if (lower.includes(kw)) {
|
|
44
|
+
matched.push(domain);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// safety needs a ZIP to be useful on its own
|
|
51
|
+
if (matched.includes('safety') && matched.length > 1) {
|
|
52
|
+
const idx = matched.indexOf('safety');
|
|
53
|
+
matched.splice(idx, 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// compare needs two things — keep it only if explicitly comparing
|
|
57
|
+
if (matched.includes('compare') && matched.length > 1) {
|
|
58
|
+
// keep compare alongside the domain being compared
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return matched.length > 0 ? matched : ['unknown'];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractZip(q) {
|
|
65
|
+
const m = q.match(/\b(\d{5})\b/);
|
|
66
|
+
return m ? m[1] : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractZips(q) {
|
|
70
|
+
const matches = q.match(/\b\d{5}\b/g);
|
|
71
|
+
return matches || [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function extractDrugName(q) {
|
|
75
|
+
const lower = q.toLowerCase();
|
|
76
|
+
const patterns = [
|
|
77
|
+
/(?:drug|medication|medicine|prescription)\s+(?:called\s+)?(\w+)/i,
|
|
78
|
+
/(?:side effects?\s+(?:of|for)\s+)(\w+)/i,
|
|
79
|
+
/(?:is\s+)(\w+)\s+(?:safe|dangerous)/i,
|
|
80
|
+
];
|
|
81
|
+
for (const p of patterns) {
|
|
82
|
+
const m = lower.match(p);
|
|
83
|
+
if (m) return m[1];
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function extractCompanyName(q) {
|
|
89
|
+
const patterns = [
|
|
90
|
+
/(?:company|stock|ticker|shares?\s+of)\s+(\w+)/i,
|
|
91
|
+
/(\b[A-Z]{1,5}\b)\s+(?:stock|filing|10-k|earnings)/,
|
|
92
|
+
/(?:sec|filing)\s+(?:for\s+)?(\w+)/i,
|
|
93
|
+
];
|
|
94
|
+
for (const p of patterns) {
|
|
95
|
+
const m = q.match(p);
|
|
96
|
+
if (m) return m[1];
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractFoodItem(q) {
|
|
102
|
+
const patterns = [
|
|
103
|
+
/(?:calories\s+in|nutrition\s+(?:of|for|in))\s+(.+?)(?:\?|$)/i,
|
|
104
|
+
/(?:how\s+(?:much|many)\s+(?:protein|fat|carbs|calories|fiber|sodium)\s+(?:in|does))\s+(.+?)(?:\s+have)?(?:\?|$)/i,
|
|
105
|
+
];
|
|
106
|
+
for (const p of patterns) {
|
|
107
|
+
const m = q.match(p);
|
|
108
|
+
if (m) return m[1].trim();
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractStateCode(q) {
|
|
114
|
+
const m = q.match(/\b([A-Z]{2})\b/);
|
|
115
|
+
if (m) {
|
|
116
|
+
const valid = [
|
|
117
|
+
'AL','AK','AZ','AR','CA','CO','CT','DE','FL','GA','HI','ID','IL','IN','IA',
|
|
118
|
+
'KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT','NE','NV','NH','NJ',
|
|
119
|
+
'NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VT',
|
|
120
|
+
'VA','WA','WV','WI','WY','DC',
|
|
121
|
+
];
|
|
122
|
+
if (valid.includes(m[1])) return m[1];
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function extractAirline(q) {
|
|
128
|
+
const lower = q.toLowerCase();
|
|
129
|
+
const map = {
|
|
130
|
+
delta: 'DL', united: 'UA', american: 'AA', southwest: 'WN',
|
|
131
|
+
alaska: 'AS', jetblue: 'B6', allegiant: 'G4', frontier: 'F9',
|
|
132
|
+
};
|
|
133
|
+
for (const [name, code] of Object.entries(map)) {
|
|
134
|
+
if (lower.includes(name)) return code;
|
|
135
|
+
}
|
|
136
|
+
const m = q.match(/\b(DL|UA|AA|WN|AS|B6|G4|F9)\b/);
|
|
137
|
+
return m ? m[1] : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractCarMakeModel(q) {
|
|
141
|
+
const patterns = [
|
|
142
|
+
/(?:safety|crash|rating)\s+(?:of|for)\s+(?:(?:a|the)\s+)?(\d{4})?\s*(\w+)\s+(\w+)/i,
|
|
143
|
+
/(\d{4})?\s*(\w+)\s+(\w+)\s+(?:safety|crash|rating|recall)/i,
|
|
144
|
+
];
|
|
145
|
+
for (const p of patterns) {
|
|
146
|
+
const m = q.match(p);
|
|
147
|
+
if (m) return { year: m[1] || null, make: m[2], model: m[3] };
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function callDomain(domain, question) {
|
|
153
|
+
const zip = extractZip(question);
|
|
154
|
+
const zips = extractZips(question);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
switch (domain) {
|
|
158
|
+
case 'water': {
|
|
159
|
+
if (!zip) return { domain, error: 'No ZIP code found in question' };
|
|
160
|
+
return await water.searchByZip(zip);
|
|
161
|
+
}
|
|
162
|
+
case 'hospitals': {
|
|
163
|
+
if (!zip) return { domain, error: 'No ZIP code found in question' };
|
|
164
|
+
return await hospitals.searchHospitals(zip);
|
|
165
|
+
}
|
|
166
|
+
case 'drugs': {
|
|
167
|
+
const name = extractDrugName(question);
|
|
168
|
+
if (!name) return { domain, error: 'No drug name found in question' };
|
|
169
|
+
return await drugs.getDrug(name);
|
|
170
|
+
}
|
|
171
|
+
case 'food': {
|
|
172
|
+
const foodItem = extractFoodItem(question);
|
|
173
|
+
return await food.search(foodItem || question);
|
|
174
|
+
}
|
|
175
|
+
case 'cars': {
|
|
176
|
+
const car = extractCarMakeModel(question);
|
|
177
|
+
if (!car) return { domain, error: 'No make/model found in question' };
|
|
178
|
+
return await cars.getSafety(car.make, car.model, car.year);
|
|
179
|
+
}
|
|
180
|
+
case 'flights': {
|
|
181
|
+
const airline = extractAirline(question);
|
|
182
|
+
if (airline) return await flights.getAirline(airline);
|
|
183
|
+
return await flights.getAirlines();
|
|
184
|
+
}
|
|
185
|
+
case 'health': {
|
|
186
|
+
const lower = question.toLowerCase();
|
|
187
|
+
const healthPatterns = [
|
|
188
|
+
/(?:does|is)\s+(\w[\w\s]*?)\s+(?:work|effective|safe|helpful)/i,
|
|
189
|
+
/(?:evidence\s+for|studies?\s+on)\s+(\w[\w\s]*?)(?:\?|$)/i,
|
|
190
|
+
];
|
|
191
|
+
let term = null;
|
|
192
|
+
for (const p of healthPatterns) {
|
|
193
|
+
const m = lower.match(p);
|
|
194
|
+
if (m) { term = m[1].trim(); break; }
|
|
195
|
+
}
|
|
196
|
+
return await health.searchHealth(term || question);
|
|
197
|
+
}
|
|
198
|
+
case 'nutrition': {
|
|
199
|
+
const item = extractFoodItem(question);
|
|
200
|
+
if (!item) return { domain, error: 'No food item found in question' };
|
|
201
|
+
return await nutrition.searchFood(item);
|
|
202
|
+
}
|
|
203
|
+
case 'jobs': {
|
|
204
|
+
return await jobs.getUnemployment();
|
|
205
|
+
}
|
|
206
|
+
case 'demographics': {
|
|
207
|
+
if (!zip) return { domain, error: 'No ZIP code found in question' };
|
|
208
|
+
return await demographics.getByZip(zip);
|
|
209
|
+
}
|
|
210
|
+
case 'products': {
|
|
211
|
+
return await products.search(question);
|
|
212
|
+
}
|
|
213
|
+
case 'sec': {
|
|
214
|
+
const company = extractCompanyName(question);
|
|
215
|
+
if (!company) return { domain, error: 'No company name found in question' };
|
|
216
|
+
return await sec.searchCompany(company);
|
|
217
|
+
}
|
|
218
|
+
case 'weather': {
|
|
219
|
+
if (zip) return await weather.getForecastByZip(zip);
|
|
220
|
+
return { domain, error: 'No ZIP code or coordinates found in question' };
|
|
221
|
+
}
|
|
222
|
+
case 'safety': {
|
|
223
|
+
if (!zip) return { domain, error: 'No ZIP code found in question' };
|
|
224
|
+
return await safety.getSafetyProfile(zip);
|
|
225
|
+
}
|
|
226
|
+
case 'compare': {
|
|
227
|
+
if (zips.length >= 2) return await compare.compareZips(zips[0], zips[1]);
|
|
228
|
+
return { domain, error: 'Comparison requires two ZIP codes or two items' };
|
|
229
|
+
}
|
|
230
|
+
default:
|
|
231
|
+
return { domain, error: 'Unknown domain' };
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return { domain, error: err.message };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function summarize(question, domains, results) {
|
|
239
|
+
const errors = results.filter(r => r.data.error);
|
|
240
|
+
const good = results.filter(r => !r.data.error);
|
|
241
|
+
|
|
242
|
+
if (good.length === 0) {
|
|
243
|
+
return `Could not find data to answer: "${question}"`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const parts = [];
|
|
247
|
+
|
|
248
|
+
for (const r of good) {
|
|
249
|
+
const d = r.data;
|
|
250
|
+
switch (r.domain) {
|
|
251
|
+
case 'water': {
|
|
252
|
+
const count = d.systems ? d.systems.length : 0;
|
|
253
|
+
const violations = d.systems
|
|
254
|
+
? d.systems.reduce((sum, s) => sum + (s.violations || 0), 0)
|
|
255
|
+
: (d.violations ? d.violations.total : 0);
|
|
256
|
+
if (violations > 0) parts.push(`${count} water system(s) found with ${violations} violation(s)`);
|
|
257
|
+
else parts.push(`${count} water system(s) found with no violations on record`);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case 'hospitals': {
|
|
261
|
+
const count = (d.hospitals || d.results || []).length;
|
|
262
|
+
parts.push(`${count} hospital(s) found nearby`);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
case 'drugs': {
|
|
266
|
+
const name = d.drugName || d.name || 'this drug';
|
|
267
|
+
const events = d.adverseEvents || d.totalEvents;
|
|
268
|
+
if (events) parts.push(`${name} has ${events} reported adverse events on file`);
|
|
269
|
+
else parts.push(`Data retrieved for ${name}`);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case 'food': {
|
|
273
|
+
const count = d.total || (d.recalls || d.results || []).length;
|
|
274
|
+
parts.push(`${count} food recall(s) found`);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case 'cars': {
|
|
278
|
+
const rating = d.overallRating || d.rating;
|
|
279
|
+
if (rating) parts.push(`Overall safety rating: ${rating}/5 stars`);
|
|
280
|
+
else parts.push('Vehicle safety data retrieved');
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case 'flights': {
|
|
284
|
+
if (d.airline) parts.push(`${d.airline}: on-time ${d.onTimePercent || 'N/A'}%`);
|
|
285
|
+
else parts.push('Airline data retrieved');
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
case 'health': {
|
|
289
|
+
const evidence = d.evidenceLevel || d.evidence;
|
|
290
|
+
const name = d.supplement || d.query || 'this supplement';
|
|
291
|
+
if (evidence) parts.push(`${name}: evidence level is ${evidence}`);
|
|
292
|
+
else parts.push(`Research data retrieved for ${name}`);
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
case 'nutrition': {
|
|
296
|
+
const foods = d.foods || d.results || [];
|
|
297
|
+
if (foods.length > 0) {
|
|
298
|
+
const first = foods[0];
|
|
299
|
+
const cal = first.calories || first.energy;
|
|
300
|
+
if (cal) parts.push(`${first.name || first.description}: ${cal} calories per serving`);
|
|
301
|
+
else parts.push(`Nutrition data found for ${foods.length} item(s)`);
|
|
302
|
+
}
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case 'demographics': {
|
|
306
|
+
if (d.population) parts.push(`Population: ${d.population.toLocaleString()}, median income: $${(d.medianIncome || 0).toLocaleString()}`);
|
|
307
|
+
else parts.push('Demographics data retrieved');
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
case 'weather': {
|
|
311
|
+
const periods = d.periods || d.forecast || [];
|
|
312
|
+
if (periods.length > 0) {
|
|
313
|
+
const p = periods[0];
|
|
314
|
+
parts.push(`${p.name || 'Now'}: ${p.shortForecast || p.detailedForecast || ''}, ${p.temperature || ''}${p.temperatureUnit || ''}`);
|
|
315
|
+
} else {
|
|
316
|
+
parts.push('Weather data retrieved');
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
case 'safety': {
|
|
321
|
+
const score = d.safetyScore;
|
|
322
|
+
if (score != null) parts.push(`Safety score: ${score}/100`);
|
|
323
|
+
else parts.push('Safety profile retrieved');
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
default:
|
|
327
|
+
parts.push(`${r.domain} data retrieved`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return parts.join('; ') + '.';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function askQuestion(question) {
|
|
335
|
+
if (!question || typeof question !== 'string' || question.trim().length === 0) {
|
|
336
|
+
return {
|
|
337
|
+
domain: 'ask',
|
|
338
|
+
question: question || '',
|
|
339
|
+
routed_to: [],
|
|
340
|
+
freshness: new Date().toISOString(),
|
|
341
|
+
results: [],
|
|
342
|
+
answer: 'No question provided.',
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const q = question.trim();
|
|
347
|
+
const domains = detectDomains(q);
|
|
348
|
+
|
|
349
|
+
const calls = domains.map(async (domain) => {
|
|
350
|
+
const data = await callDomain(domain, q);
|
|
351
|
+
return { domain, data };
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const results = await Promise.all(calls);
|
|
355
|
+
const answer = summarize(q, domains, results);
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
domain: 'ask',
|
|
359
|
+
question: q,
|
|
360
|
+
routed_to: domains,
|
|
361
|
+
freshness: new Date().toISOString(),
|
|
362
|
+
results,
|
|
363
|
+
answer,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
module.exports = { askQuestion, detectDomains };
|
package/sources/cars.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const fetch = require('node-fetch');
|
|
2
|
+
|
|
3
|
+
const NHTSA_BASE = 'https://api.nhtsa.gov';
|
|
4
|
+
|
|
5
|
+
async function fetchWithTimeout(url, ms = 10000) {
|
|
6
|
+
const ctrl = new AbortController();
|
|
7
|
+
const id = setTimeout(() => ctrl.abort(), ms);
|
|
8
|
+
try {
|
|
9
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
10
|
+
clearTimeout(id);
|
|
11
|
+
if (!res.ok) return null;
|
|
12
|
+
return await res.json();
|
|
13
|
+
} catch {
|
|
14
|
+
clearTimeout(id);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getSafety(year, make, model) {
|
|
20
|
+
if (!year || !make || !model) return { error: 'year, make, and model are required' };
|
|
21
|
+
|
|
22
|
+
const [ratingsData, recallsData] = await Promise.all([
|
|
23
|
+
fetchWithTimeout(`${NHTSA_BASE}/SafetyRatings/modelyear/${year}/make/${encodeURIComponent(make)}/model/${encodeURIComponent(model)}?format=json`),
|
|
24
|
+
fetchWithTimeout(`${NHTSA_BASE}/recalls/recallsByVehicle?make=${encodeURIComponent(make)}&model=${encodeURIComponent(model)}&modelYear=${year}`)
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const variants = ratingsData && ratingsData.Results ? ratingsData.Results : [];
|
|
28
|
+
let ratings = null;
|
|
29
|
+
|
|
30
|
+
if (variants.length > 0 && variants[0].VehicleId) {
|
|
31
|
+
const detail = await fetchWithTimeout(`${NHTSA_BASE}/SafetyRatings/VehicleId/${variants[0].VehicleId}?format=json`);
|
|
32
|
+
if (detail && detail.Results && detail.Results.length > 0) {
|
|
33
|
+
ratings = detail.Results[0];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const recalls = recallsData && recallsData.results ? recallsData.results : [];
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
domain: 'cars',
|
|
41
|
+
source: 'NHTSA',
|
|
42
|
+
source_url: 'https://api.nhtsa.gov',
|
|
43
|
+
freshness: new Date().toISOString(),
|
|
44
|
+
year, make, model,
|
|
45
|
+
ratings,
|
|
46
|
+
variants: variants.map(v => ({ vehicleId: v.VehicleId, description: v.VehicleDescription })),
|
|
47
|
+
recalls: recalls.map(r => ({
|
|
48
|
+
component: r.Component,
|
|
49
|
+
summary: r.Summary,
|
|
50
|
+
consequence: r.Consequence,
|
|
51
|
+
remedy: r.Remedy,
|
|
52
|
+
})),
|
|
53
|
+
recallCount: recalls.length,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { getSafety };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const demographics = require('./demographics');
|
|
2
|
+
const safety = require('./safety');
|
|
3
|
+
const drugs = require('./drugs');
|
|
4
|
+
const hospitals = require('./hospitals');
|
|
5
|
+
|
|
6
|
+
function fmt(n) {
|
|
7
|
+
if (n == null || isNaN(n)) return 'N/A';
|
|
8
|
+
return n.toLocaleString('en-US');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function compareZips(zip1, zip2) {
|
|
12
|
+
const [demo1, demo2, safety1, safety2] = await Promise.all([
|
|
13
|
+
demographics.getByZip(zip1),
|
|
14
|
+
demographics.getByZip(zip2),
|
|
15
|
+
safety.getSafetyProfile(zip1),
|
|
16
|
+
safety.getSafetyProfile(zip2),
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
if (demo1.error) return { error: `ZIP ${zip1}: ${demo1.error}` };
|
|
20
|
+
if (demo2.error) return { error: `ZIP ${zip2}: ${demo2.error}` };
|
|
21
|
+
|
|
22
|
+
const incomeWinner = demo1.medianIncome >= demo2.medianIncome ? zip1 : zip2;
|
|
23
|
+
const incomeLoser = incomeWinner === zip1 ? zip2 : zip1;
|
|
24
|
+
const incomeHigh = Math.max(demo1.medianIncome, demo2.medianIncome);
|
|
25
|
+
const incomeLow = Math.min(demo1.medianIncome, demo2.medianIncome);
|
|
26
|
+
|
|
27
|
+
const safetyWinner = (safety1.safetyScore || 0) >= (safety2.safetyScore || 0) ? zip1 : zip2;
|
|
28
|
+
const safetyHigh = Math.max(safety1.safetyScore || 0, safety2.safetyScore || 0);
|
|
29
|
+
const safetyLow = Math.min(safety1.safetyScore || 0, safety2.safetyScore || 0);
|
|
30
|
+
|
|
31
|
+
const verdict = `ZIP ${incomeWinner} has higher income ($${fmt(incomeHigh)} vs $${fmt(incomeLow)}) and ${safetyWinner === incomeWinner ? 'better' : `ZIP ${safetyWinner} has better`} safety (${safetyHigh} vs ${safetyLow})`;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
domain: 'compare',
|
|
35
|
+
type: 'location',
|
|
36
|
+
freshness: new Date().toISOString(),
|
|
37
|
+
a: { zip: zip1, demographics: demo1, safetyScore: safety1.safetyScore || 0 },
|
|
38
|
+
b: { zip: zip2, demographics: demo2, safetyScore: safety2.safetyScore || 0 },
|
|
39
|
+
verdict,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function compareDrugs(drug1, drug2) {
|
|
44
|
+
const [a, b] = await Promise.all([
|
|
45
|
+
drugs.getDrug(drug1),
|
|
46
|
+
drugs.getDrug(drug2),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
if (a.error) return { error: `${drug1}: ${a.error}` };
|
|
50
|
+
if (b.error) return { error: `${drug2}: ${b.error}` };
|
|
51
|
+
|
|
52
|
+
const topReactionA = a.topReactions && a.topReactions[0] ? a.topReactions[0].reaction : 'none reported';
|
|
53
|
+
const topReactionB = b.topReactions && b.topReactions[0] ? b.topReactions[0].reaction : 'none reported';
|
|
54
|
+
|
|
55
|
+
const fewer = a.totalEvents <= b.totalEvents ? drug1 : drug2;
|
|
56
|
+
const low = Math.min(a.totalEvents, b.totalEvents);
|
|
57
|
+
const high = Math.max(a.totalEvents, b.totalEvents);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
domain: 'compare',
|
|
61
|
+
type: 'drugs',
|
|
62
|
+
freshness: new Date().toISOString(),
|
|
63
|
+
a: { name: drug1, totalEvents: a.totalEvents, seriousEvents: a.seriousEvents, topReaction: topReactionA },
|
|
64
|
+
b: { name: drug2, totalEvents: b.totalEvents, seriousEvents: b.seriousEvents, topReaction: topReactionB },
|
|
65
|
+
verdict: `${fewer} has fewer adverse events (${fmt(low)} vs ${fmt(high)})`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function compareHospitals(id1, id2) {
|
|
70
|
+
const [a, b] = await Promise.all([
|
|
71
|
+
hospitals.getHospital(id1),
|
|
72
|
+
hospitals.getHospital(id2),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
if (a.error) return { error: `${id1}: ${a.error}` };
|
|
76
|
+
if (b.error) return { error: `${id2}: ${b.error}` };
|
|
77
|
+
|
|
78
|
+
const ratingA = parseInt(a.overallRating) || 0;
|
|
79
|
+
const ratingB = parseInt(b.overallRating) || 0;
|
|
80
|
+
|
|
81
|
+
const better = ratingA >= ratingB ? a : b;
|
|
82
|
+
const worse = ratingA >= ratingB ? b : a;
|
|
83
|
+
const betterRating = Math.max(ratingA, ratingB);
|
|
84
|
+
const worseRating = Math.min(ratingA, ratingB);
|
|
85
|
+
|
|
86
|
+
const verdict = betterRating === worseRating
|
|
87
|
+
? `${a.name} and ${b.name} share the same overall rating (${betterRating}/5)`
|
|
88
|
+
: `${better.name} rates higher (${betterRating}/5 vs ${worseRating}/5)`;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
domain: 'compare',
|
|
92
|
+
type: 'hospitals',
|
|
93
|
+
freshness: new Date().toISOString(),
|
|
94
|
+
a: { providerId: id1, name: a.name, overallRating: ratingA, city: a.city, state: a.state },
|
|
95
|
+
b: { providerId: id2, name: b.name, overallRating: ratingB, city: b.city, state: b.state },
|
|
96
|
+
verdict,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { compareZips, compareDrugs, compareHospitals };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const fetch = require('node-fetch');
|
|
2
|
+
|
|
3
|
+
const API_BASE = 'https://api.census.gov/data';
|
|
4
|
+
|
|
5
|
+
function fetchWithTimeout(url, opts = {}, ms = 8000) {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
8
|
+
return fetch(url, { ...opts, signal: controller.signal }).finally(() => clearTimeout(timer));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function getByZip(zip) {
|
|
12
|
+
const variables = [
|
|
13
|
+
'NAME',
|
|
14
|
+
'B01003_001E', // total population
|
|
15
|
+
'B19013_001E', // median household income
|
|
16
|
+
'B17001_002E', // poverty count
|
|
17
|
+
'B17001_001E', // poverty universe total
|
|
18
|
+
'B15003_022E', // bachelor's
|
|
19
|
+
'B15003_023E', // master's
|
|
20
|
+
'B15003_024E', // professional
|
|
21
|
+
'B15003_025E', // doctorate
|
|
22
|
+
'B25077_001E', // median home value
|
|
23
|
+
'B25064_001E', // median gross rent
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const url = `${API_BASE}/2023/acs/acs5?get=${variables.join(',')}&for=zip%20code%20tabulation%20area:${zip}`;
|
|
27
|
+
const res = await fetchWithTimeout(url);
|
|
28
|
+
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
throw new Error(`Census API returned ${res.status}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rows = await res.json();
|
|
34
|
+
|
|
35
|
+
if (!rows || rows.length < 2) {
|
|
36
|
+
throw new Error(`No data found for ZIP ${zip}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const headers = rows[0];
|
|
40
|
+
const values = rows[1];
|
|
41
|
+
const data = {};
|
|
42
|
+
headers.forEach((h, i) => { data[h] = values[i]; });
|
|
43
|
+
|
|
44
|
+
const population = Number(data['B01003_001E']);
|
|
45
|
+
const medianIncome = Number(data['B19013_001E']);
|
|
46
|
+
const povertyCount = Number(data['B17001_002E']);
|
|
47
|
+
const povertyTotal = Number(data['B17001_001E']);
|
|
48
|
+
const bachelors = Number(data['B15003_022E']);
|
|
49
|
+
const masters = Number(data['B15003_023E']);
|
|
50
|
+
const professional = Number(data['B15003_024E']);
|
|
51
|
+
const doctorate = Number(data['B15003_025E']);
|
|
52
|
+
const medianHomeValue = Number(data['B25077_001E']);
|
|
53
|
+
const medianRent = Number(data['B25064_001E']);
|
|
54
|
+
|
|
55
|
+
const povertyRate = povertyTotal > 0 ? Math.round((povertyCount / povertyTotal) * 1000) / 10 : null;
|
|
56
|
+
const collegePlus = bachelors + masters + professional + doctorate;
|
|
57
|
+
const collegeRate = population > 0 ? Math.round((collegePlus / population) * 1000) / 10 : null;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
domain: 'demographics',
|
|
61
|
+
source: 'US Census ACS 5-Year',
|
|
62
|
+
source_url: 'https://api.census.gov',
|
|
63
|
+
freshness: '2023 5-year estimates',
|
|
64
|
+
zip,
|
|
65
|
+
population,
|
|
66
|
+
medianIncome,
|
|
67
|
+
povertyRate,
|
|
68
|
+
collegeRate,
|
|
69
|
+
medianHomeValue,
|
|
70
|
+
medianRent,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { getByZip };
|
package/sources/drugs.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const fetch = require('node-fetch');
|
|
2
|
+
|
|
3
|
+
const FDA_BASE = 'https://api.fda.gov';
|
|
4
|
+
|
|
5
|
+
async function fetchWithTimeout(url, ms = 10000) {
|
|
6
|
+
const ctrl = new AbortController();
|
|
7
|
+
const id = setTimeout(() => ctrl.abort(), ms);
|
|
8
|
+
try {
|
|
9
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
10
|
+
clearTimeout(id);
|
|
11
|
+
if (!res.ok) return null;
|
|
12
|
+
return await res.json();
|
|
13
|
+
} catch {
|
|
14
|
+
clearTimeout(id);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getEventCount(searchParam) {
|
|
20
|
+
const url = `${FDA_BASE}/drug/event.json?search=${searchParam}&limit=1`;
|
|
21
|
+
const data = await fetchWithTimeout(url);
|
|
22
|
+
return data && data.meta ? data.meta.results.total : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function getDrug(name) {
|
|
26
|
+
if (!name) return { error: 'Drug name required' };
|
|
27
|
+
|
|
28
|
+
const brandParam = `patient.drug.medicinalproduct:"${encodeURIComponent(name)}"`;
|
|
29
|
+
const genericParam = `patient.drug.openfda.generic_name:"${encodeURIComponent(name)}"`;
|
|
30
|
+
|
|
31
|
+
const [brandCount, genericCount] = await Promise.all([
|
|
32
|
+
getEventCount(brandParam),
|
|
33
|
+
getEventCount(genericParam),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
if (brandCount === 0 && genericCount === 0) return { error: 'No adverse event reports found for this drug' };
|
|
37
|
+
|
|
38
|
+
const searchParam = brandCount >= genericCount ? brandParam : genericParam;
|
|
39
|
+
const year = new Date().getFullYear();
|
|
40
|
+
const prevYear = year - 1;
|
|
41
|
+
|
|
42
|
+
const [serious, deaths, recent, reactions, label] = await Promise.all([
|
|
43
|
+
fetchWithTimeout(`${FDA_BASE}/drug/event.json?search=${searchParam}+AND+serious:1&limit=1`),
|
|
44
|
+
fetchWithTimeout(`${FDA_BASE}/drug/event.json?search=${searchParam}+AND+seriousnessdeath:1&limit=1`),
|
|
45
|
+
fetchWithTimeout(`${FDA_BASE}/drug/event.json?search=${searchParam}+AND+receivedate:[${prevYear}0101+TO+${year}1231]&limit=1`),
|
|
46
|
+
fetchWithTimeout(`${FDA_BASE}/drug/event.json?search=${searchParam}&count=patient.reaction.reactionmeddrapt.exact&limit=10`),
|
|
47
|
+
fetchWithTimeout(`${FDA_BASE}/drug/label.json?search=openfda.brand_name:"${encodeURIComponent(name)}"+OR+openfda.generic_name:"${encodeURIComponent(name)}"&limit=1`),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const total = Math.max(brandCount, genericCount);
|
|
51
|
+
const labelResult = label && label.results && label.results[0] ? label.results[0] : null;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
domain: 'drugs',
|
|
55
|
+
source: 'FDA FAERS + Drug Labels',
|
|
56
|
+
source_url: 'https://api.fda.gov',
|
|
57
|
+
freshness: new Date().toISOString(),
|
|
58
|
+
drug: name,
|
|
59
|
+
totalEvents: total,
|
|
60
|
+
seriousEvents: serious && serious.meta ? serious.meta.results.total : 0,
|
|
61
|
+
deathEvents: deaths && deaths.meta ? deaths.meta.results.total : 0,
|
|
62
|
+
recentEvents: recent && recent.meta ? recent.meta.results.total : 0,
|
|
63
|
+
topReactions: reactions && reactions.results ? reactions.results.map(r => ({
|
|
64
|
+
reaction: r.term,
|
|
65
|
+
count: r.count,
|
|
66
|
+
})) : [],
|
|
67
|
+
labelWarnings: labelResult ? {
|
|
68
|
+
warnings: (labelResult.warnings || ['']).join(' ').slice(0, 1500) || null,
|
|
69
|
+
adverseReactions: (labelResult.adverse_reactions || ['']).join(' ').slice(0, 1000) || null,
|
|
70
|
+
} : null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { getDrug };
|