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.
@@ -0,0 +1,125 @@
1
+ const fetch = require('node-fetch');
2
+
3
+ const AIRLINES = [
4
+ { name: 'Delta', iata: 'DL', hubs: ['ATL', 'MSP', 'DTW', 'SLC'] },
5
+ { name: 'United', iata: 'UA', hubs: ['ORD', 'EWR', 'IAH', 'DEN', 'SFO'] },
6
+ { name: 'American', iata: 'AA', hubs: ['DFW', 'CLT', 'MIA', 'PHX', 'ORD'] },
7
+ { name: 'Southwest', iata: 'WN', hubs: ['DAL', 'MDW', 'BWI', 'LAS', 'DEN'] },
8
+ { name: 'Alaska', iata: 'AS', hubs: ['SEA', 'PDX', 'SFO'] },
9
+ { name: 'JetBlue', iata: 'B6', hubs: ['JFK', 'BOS', 'FLL'] },
10
+ { name: 'Allegiant', iata: 'G4', hubs: ['LAS', 'SFB', 'PIE'] },
11
+ { name: 'Frontier', iata: 'F9', hubs: ['DEN', 'LAS', 'MCO'] },
12
+ ];
13
+
14
+ const HUB_COORDS = {
15
+ ATL: [33.64, -84.43], MSP: [44.88, -93.22], DTW: [42.21, -83.35], SLC: [40.79, -111.98],
16
+ ORD: [41.97, -87.91], EWR: [40.69, -74.17], IAH: [29.98, -95.34], DEN: [39.86, -104.67],
17
+ SFO: [37.62, -122.38], DFW: [32.90, -97.04], CLT: [35.21, -80.94], MIA: [25.79, -80.29],
18
+ PHX: [33.43, -112.01], DAL: [32.85, -96.85], MDW: [41.79, -87.75], BWI: [39.18, -76.67],
19
+ LAS: [36.08, -115.15], SEA: [47.45, -122.31], PDX: [45.59, -122.60],
20
+ JFK: [40.64, -73.78], BOS: [42.36, -71.01], FLL: [26.07, -80.15],
21
+ SFB: [28.78, -81.24], PIE: [27.91, -82.69], MCO: [28.43, -81.31],
22
+ };
23
+
24
+ async function fetchWithTimeout(url, ms = 10000) {
25
+ const ctrl = new AbortController();
26
+ const id = setTimeout(() => ctrl.abort(), ms);
27
+ try {
28
+ const res = await fetch(url, { signal: ctrl.signal, headers: { 'User-Agent': 'OpenPrimitive/1.0' } });
29
+ clearTimeout(id);
30
+ return res;
31
+ } catch (e) {
32
+ clearTimeout(id);
33
+ return null;
34
+ }
35
+ }
36
+
37
+ async function getFaaDelays() {
38
+ const res = await fetchWithTimeout('https://nasstatus.faa.gov/api/airport-status-information', 10000);
39
+ if (!res) return {};
40
+ try {
41
+ const text = await res.text();
42
+ const delays = {};
43
+ const airportPattern = /IATA>(\w{3})<.*?Reason>(.*?)<.*?avgDelay>(.*?)</gs;
44
+ let match;
45
+ while ((match = airportPattern.exec(text)) !== null) {
46
+ delays[match[1]] = { reason: match[2], delay: match[3] };
47
+ }
48
+ return delays;
49
+ } catch { return {}; }
50
+ }
51
+
52
+ async function getWeather(hub) {
53
+ const coords = HUB_COORDS[hub];
54
+ if (!coords) return null;
55
+ const url = `https://api.open-meteo.com/v1/forecast?latitude=${coords[0]}&longitude=${coords[1]}&daily=weather_code,precipitation_sum&timezone=America/New_York&forecast_days=4`;
56
+ const res = await fetchWithTimeout(url, 8000);
57
+ if (!res) return null;
58
+ try {
59
+ const data = await res.json();
60
+ return {
61
+ hub,
62
+ daily: data.daily ? data.daily.weather_code.map((code, i) => ({
63
+ day: i + 1,
64
+ weatherCode: code,
65
+ precipitation: data.daily.precipitation_sum[i]
66
+ })) : []
67
+ };
68
+ } catch { return null; }
69
+ }
70
+
71
+ // Exported query functions
72
+
73
+ async function getAirlines() {
74
+ const [faaDelays, ...weatherResults] = await Promise.all([
75
+ getFaaDelays(),
76
+ ...AIRLINES.map(a => getWeather(a.hubs[0]))
77
+ ]);
78
+
79
+ return {
80
+ domain: 'flights',
81
+ source: 'FAA NAS + Open-Meteo',
82
+ source_url: 'https://nasstatus.faa.gov',
83
+ freshness: new Date().toISOString(),
84
+ airlines: AIRLINES.map((airline, i) => {
85
+ const hubDelays = airline.hubs
86
+ .filter(h => faaDelays[h])
87
+ .map(h => ({ airport: h, ...faaDelays[h] }));
88
+ const weather = weatherResults[i];
89
+ return {
90
+ name: airline.name,
91
+ iata: airline.iata,
92
+ hubs: airline.hubs,
93
+ delays: hubDelays,
94
+ hasDelays: hubDelays.length > 0,
95
+ weather: weather ? weather.daily : [],
96
+ primaryHub: airline.hubs[0],
97
+ };
98
+ })
99
+ };
100
+ }
101
+
102
+ async function getAirline(iata) {
103
+ const airline = AIRLINES.find(a => a.iata.toUpperCase() === iata.toUpperCase());
104
+ if (!airline) return null;
105
+ const [faaDelays, weather] = await Promise.all([
106
+ getFaaDelays(),
107
+ getWeather(airline.hubs[0])
108
+ ]);
109
+ const hubDelays = airline.hubs
110
+ .filter(h => faaDelays[h])
111
+ .map(h => ({ airport: h, ...faaDelays[h] }));
112
+ return {
113
+ domain: 'flights',
114
+ source: 'FAA NAS + Open-Meteo',
115
+ freshness: new Date().toISOString(),
116
+ name: airline.name,
117
+ iata: airline.iata,
118
+ hubs: airline.hubs,
119
+ delays: hubDelays,
120
+ hasDelays: hubDelays.length > 0,
121
+ weather: weather ? weather.daily : [],
122
+ };
123
+ }
124
+
125
+ module.exports = { getAirlines, getAirline, AIRLINES };
@@ -0,0 +1,74 @@
1
+ const fetch = require('node-fetch');
2
+
3
+ const FDA_BASE = 'https://api.fda.gov/food/enforcement.json';
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 && res.status !== 404) return null;
12
+ if (res.status === 404) return { meta: { results: { total: 0 } }, results: [] };
13
+ return await res.json();
14
+ } catch {
15
+ clearTimeout(id);
16
+ return null;
17
+ }
18
+ }
19
+
20
+ async function getRecent() {
21
+ const now = new Date();
22
+ const start = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
23
+ const fmt = d => d.toISOString().slice(0, 10).replace(/-/g, '');
24
+ const url = `${FDA_BASE}?search=status:"Ongoing"+AND+report_date:[${fmt(start)}+TO+${fmt(now)}]&sort=report_date:desc&limit=20`;
25
+ const data = await fetchWithTimeout(url);
26
+ if (!data) return { error: 'FDA API unavailable' };
27
+ return {
28
+ domain: 'food',
29
+ source: 'FDA Enforcement',
30
+ source_url: 'https://api.fda.gov',
31
+ freshness: new Date().toISOString(),
32
+ total: data.meta ? data.meta.results.total : 0,
33
+ recalls: (data.results || []).map(r => ({
34
+ product: r.product_description,
35
+ firm: r.recalling_firm,
36
+ classification: r.classification,
37
+ status: r.status,
38
+ reason: r.reason_for_recall,
39
+ distribution: r.distribution_pattern,
40
+ date: r.recall_initiation_date,
41
+ quantity: r.product_quantity,
42
+ recallNumber: r.recall_number,
43
+ })),
44
+ };
45
+ }
46
+
47
+ async function search(query) {
48
+ if (!query) return { error: 'query is required' };
49
+ const encoded = encodeURIComponent(query);
50
+ const url = `${FDA_BASE}?search=product_description:"${encoded}"+OR+recalling_firm:"${encoded}"&sort=report_date:desc&limit=20`;
51
+ const data = await fetchWithTimeout(url);
52
+ if (!data) return { error: 'FDA API unavailable' };
53
+ return {
54
+ domain: 'food',
55
+ source: 'FDA Enforcement',
56
+ source_url: 'https://api.fda.gov',
57
+ freshness: new Date().toISOString(),
58
+ query,
59
+ total: data.meta ? data.meta.results.total : 0,
60
+ recalls: (data.results || []).map(r => ({
61
+ product: r.product_description,
62
+ firm: r.recalling_firm,
63
+ classification: r.classification,
64
+ status: r.status,
65
+ reason: r.reason_for_recall,
66
+ distribution: r.distribution_pattern,
67
+ date: r.recall_initiation_date,
68
+ quantity: r.product_quantity,
69
+ recallNumber: r.recall_number,
70
+ })),
71
+ };
72
+ }
73
+
74
+ module.exports = { getRecent, search };
@@ -0,0 +1,102 @@
1
+ const fetch = require('node-fetch');
2
+
3
+ const EUTILS_BASE = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
4
+ const USER_AGENT = 'OpenPrimitive/1.0 (contact: davehamiltonj@gmail.com)';
5
+
6
+ const ODS_SHEETS = {
7
+ 'vitamin d': 'VitaminD', 'vitamin c': 'VitaminC', 'vitamin b12': 'VitaminB12',
8
+ 'vitamin a': 'VitaminA', 'vitamin b6': 'VitaminB6', 'vitamin e': 'VitaminE',
9
+ 'vitamin k': 'VitaminK', 'zinc': 'Zinc', 'iron': 'Iron', 'magnesium': 'Magnesium',
10
+ 'calcium': 'Calcium', 'potassium': 'Potassium', 'selenium': 'Selenium',
11
+ 'iodine': 'Iodine', 'omega-3': 'Omega3FattyAcids', 'fish oil': 'Omega3FattyAcids',
12
+ 'probiotics': 'Probiotics', 'melatonin': 'Melatonin', 'ashwagandha': 'Ashwagandha',
13
+ 'turmeric': 'Turmeric', 'curcumin': 'Turmeric', 'creatine': 'Creatine',
14
+ 'collagen': 'Collagen', 'coq10': 'CoenzymeQ10', 'ginkgo': 'Ginkgo',
15
+ 'ginseng': 'Ginseng', 'echinacea': 'Echinacea', 'garlic': 'Garlic',
16
+ 'glucosamine': 'Glucosamine',
17
+ };
18
+
19
+ async function fetchWithTimeout(url, ms = 10000) {
20
+ const ctrl = new AbortController();
21
+ const id = setTimeout(() => ctrl.abort(), ms);
22
+ try {
23
+ const res = await fetch(url, {
24
+ signal: ctrl.signal,
25
+ headers: { 'User-Agent': USER_AGENT },
26
+ });
27
+ clearTimeout(id);
28
+ if (!res.ok) return null;
29
+ return await res.json();
30
+ } catch {
31
+ clearTimeout(id);
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function getOdsUrl(query) {
37
+ const q = query.toLowerCase();
38
+ for (const [key, val] of Object.entries(ODS_SHEETS)) {
39
+ if (q.includes(key) || key.includes(q)) {
40
+ return `https://ods.od.nih.gov/factsheets/${val}-HealthProfessional/`;
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ function evidenceLevel(count) {
47
+ if (count >= 500) return { level: 'strong', label: 'Strong research signal' };
48
+ if (count >= 50) return { level: 'moderate', label: 'Moderate research signal' };
49
+ if (count >= 10) return { level: 'limited', label: 'Limited research signal' };
50
+ if (count >= 1) return { level: 'weak', label: 'Weak research signal' };
51
+ return { level: 'none', label: 'No studies found' };
52
+ }
53
+
54
+ async function searchHealth(query) {
55
+ if (!query) return { error: 'Search query required' };
56
+
57
+ const searchUrl = `${EUTILS_BASE}/esearch.fcgi?db=pubmed&retmode=json&retmax=5&sort=pub_date&term=${encodeURIComponent(query)}[Title/Abstract]`;
58
+ const searchData = await fetchWithTimeout(searchUrl);
59
+ if (!searchData) return { error: 'PubMed unavailable' };
60
+
61
+ const result = searchData.esearchresult;
62
+ const count = parseInt(result.count || 0);
63
+ const ids = result.idlist || [];
64
+
65
+ let studies = [];
66
+ if (ids.length > 0) {
67
+ const summaryUrl = `${EUTILS_BASE}/esummary.fcgi?db=pubmed&retmode=json&id=${ids.join(',')}`;
68
+ const summaryData = await fetchWithTimeout(summaryUrl);
69
+ if (summaryData && summaryData.result) {
70
+ studies = ids.map(id => {
71
+ const s = summaryData.result[id];
72
+ if (!s) return null;
73
+ return {
74
+ id,
75
+ title: s.title,
76
+ journal: s.fulljournalname || s.source,
77
+ pubDate: s.pubdate,
78
+ authors: s.authors && s.authors.length > 0
79
+ ? (s.authors.length > 1 ? `${s.authors[0].name} et al.` : s.authors[0].name)
80
+ : 'Unknown',
81
+ url: `https://pubmed.ncbi.nlm.nih.gov/${id}/`,
82
+ };
83
+ }).filter(Boolean);
84
+ }
85
+ }
86
+
87
+ const evidence = evidenceLevel(count);
88
+ return {
89
+ domain: 'health',
90
+ source: 'PubMed/MEDLINE',
91
+ source_url: 'https://pubmed.ncbi.nlm.nih.gov',
92
+ freshness: new Date().toISOString(),
93
+ query,
94
+ totalStudies: count,
95
+ evidence: evidence.label,
96
+ evidenceLevel: evidence.level,
97
+ odsUrl: getOdsUrl(query),
98
+ studies,
99
+ };
100
+ }
101
+
102
+ module.exports = { searchHealth };
@@ -0,0 +1,115 @@
1
+ const fetch = require('node-fetch');
2
+
3
+ const CMS_BASE = 'https://data.cms.gov/provider-data/api/1/datastore/query';
4
+ const RESOURCES = {
5
+ general: 'xubh-q36u',
6
+ mortality: 'ynj2-r877',
7
+ readmissions: '632h-zaca',
8
+ experience: 'dgck-syfz',
9
+ };
10
+
11
+ async function cmsQuery(resourceId, filters, limit = 10) {
12
+ const url = `${CMS_BASE}/${resourceId}/0`;
13
+ const ctrl = new AbortController();
14
+ const id = setTimeout(() => ctrl.abort(), 10000);
15
+ try {
16
+ const res = await fetch(url, {
17
+ method: 'POST',
18
+ signal: ctrl.signal,
19
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
20
+ body: JSON.stringify({ limit, offset: 0, filters, sort: [], keys: true }),
21
+ });
22
+ clearTimeout(id);
23
+ if (!res.ok) return [];
24
+ const data = await res.json();
25
+ return data.results || [];
26
+ } catch {
27
+ clearTimeout(id);
28
+ return [];
29
+ }
30
+ }
31
+
32
+ function normalizeComparison(val) {
33
+ if (!val) return null;
34
+ const v = val.toLowerCase();
35
+ if (v.includes('better')) return 'better';
36
+ if (v.includes('worse')) return 'worse';
37
+ if (v.includes('same') || v.includes('no different')) return 'same';
38
+ return null;
39
+ }
40
+
41
+ async function searchHospitals(query) {
42
+ if (!query) return { error: 'Search query required' };
43
+
44
+ const isZip = /^\d{5}$/.test(query);
45
+ const property = isZip ? 'zip_code' : 'hospital_name';
46
+ const operator = isZip ? '=' : 'CONTAINS';
47
+
48
+ const results = await cmsQuery(RESOURCES.general, {
49
+ conditions: [{ property, value: query, operator }],
50
+ });
51
+
52
+ return {
53
+ domain: 'hospitals',
54
+ source: 'CMS Care Compare',
55
+ source_url: 'https://data.cms.gov',
56
+ freshness: new Date().toISOString(),
57
+ query,
58
+ results: results.map(h => ({
59
+ providerId: h.provider_id || h.facility_id,
60
+ name: h.hospital_name || h.facility_name,
61
+ city: h.city,
62
+ state: h.state,
63
+ zip: h.zip_code,
64
+ overallRating: h.overall_rating || null,
65
+ type: h.hospital_type,
66
+ phone: h.phone_number,
67
+ })),
68
+ count: results.length,
69
+ };
70
+ }
71
+
72
+ async function getHospital(providerId) {
73
+ if (!providerId) return { error: 'Provider ID required' };
74
+
75
+ const [general, mortality, readmissions, experience] = await Promise.all([
76
+ cmsQuery(RESOURCES.general, { conditions: [{ property: 'provider_id', value: providerId, operator: '=' }] }, 1),
77
+ cmsQuery(RESOURCES.mortality, { conditions: [{ property: 'facility_id', value: providerId, operator: '=' }] }),
78
+ cmsQuery(RESOURCES.readmissions, { conditions: [{ property: 'facility_id', value: providerId, operator: '=' }] }),
79
+ cmsQuery(RESOURCES.experience, { conditions: [{ property: 'facility_id', value: providerId, operator: '=' }] }),
80
+ ]);
81
+
82
+ const info = general[0] || {};
83
+
84
+ return {
85
+ domain: 'hospitals',
86
+ source: 'CMS Care Compare',
87
+ source_url: 'https://data.cms.gov',
88
+ freshness: new Date().toISOString(),
89
+ providerId,
90
+ name: info.hospital_name || info.facility_name || '',
91
+ city: info.city || '',
92
+ state: info.state || '',
93
+ zip: info.zip_code || '',
94
+ phone: info.phone_number || '',
95
+ type: info.hospital_type || '',
96
+ overallRating: info.overall_rating || null,
97
+ mortality: mortality.filter(m => /mortality|death/i.test(m.measure_name || m.measure_id || '')).map(m => ({
98
+ measure: m.measure_name,
99
+ score: m.score,
100
+ comparison: normalizeComparison(m.compared_to_national),
101
+ })),
102
+ readmissions: readmissions.filter(r => /readmission|return/i.test(r.measure_name || r.measure_id || '')).map(r => ({
103
+ measure: r.measure_name,
104
+ score: r.score,
105
+ comparison: normalizeComparison(r.compared_to_national),
106
+ })),
107
+ patientExperience: experience.filter(e => /summary|overall|linear/i.test(e.hcahps_measure_id || e.measure_id || '')).map(e => ({
108
+ measure: e.hcahps_question || e.measure_name,
109
+ score: e.patient_survey_star_rating || e.score,
110
+ comparison: normalizeComparison(e.patient_survey_star_rating_footnote),
111
+ })),
112
+ };
113
+ }
114
+
115
+ module.exports = { searchHospitals, getHospital };
@@ -0,0 +1,77 @@
1
+ const fetch = require('node-fetch');
2
+
3
+ const BLS_API = 'https://api.bls.gov/publicAPI/v2/timeseries/data/';
4
+
5
+ // Common series IDs:
6
+ // LNS14000000 — Unemployment rate (seasonally adjusted)
7
+ // CUUR0000SA0 — CPI, all urban consumers (seasonally adjusted)
8
+ // CES0000000001 — Total nonfarm payrolls (seasonally adjusted)
9
+
10
+ function fetchWithTimeout(url, options, timeout = 10000) {
11
+ return Promise.race([
12
+ fetch(url, options),
13
+ new Promise((_, reject) =>
14
+ setTimeout(() => reject(new Error('BLS API request timed out')), timeout)
15
+ )
16
+ ]);
17
+ }
18
+
19
+ async function fetchSeries(seriesId) {
20
+ const now = new Date();
21
+ const endYear = now.getFullYear().toString();
22
+ const startYear = (now.getFullYear() - 1).toString();
23
+
24
+ const res = await fetchWithTimeout(BLS_API, {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ body: JSON.stringify({
28
+ seriesid: [seriesId],
29
+ startyear: startYear,
30
+ endyear: endYear
31
+ })
32
+ });
33
+
34
+ if (!res.ok) {
35
+ throw new Error('BLS API returned ' + res.status);
36
+ }
37
+
38
+ const json = await res.json();
39
+
40
+ if (json.status !== 'REQUEST_SUCCEEDED' || !json.Results || !json.Results.series || !json.Results.series.length) {
41
+ throw new Error('BLS API error: ' + (json.message || 'no data'));
42
+ }
43
+
44
+ const series = json.Results.series[0];
45
+ const data = series.data || [];
46
+
47
+ // BLS returns newest first
48
+ const points = data.map(d => ({
49
+ year: d.year,
50
+ month: d.period.replace('M', ''),
51
+ value: d.value
52
+ }));
53
+
54
+ const latest = points[0] || null;
55
+
56
+ return {
57
+ domain: 'jobs',
58
+ source: 'Bureau of Labor Statistics',
59
+ source_url: 'https://www.bls.gov/',
60
+ freshness: latest ? latest.year + '-' + latest.month : null,
61
+ series: seriesId,
62
+ latest: latest,
63
+ history: points
64
+ };
65
+ }
66
+
67
+ async function getUnemployment() {
68
+ const result = await fetchSeries('LNS14000000');
69
+ result.series = 'unemployment_rate';
70
+ return result;
71
+ }
72
+
73
+ async function getSeriesData(seriesId) {
74
+ return fetchSeries(seriesId);
75
+ }
76
+
77
+ module.exports = { getUnemployment, getSeriesData };
@@ -0,0 +1,54 @@
1
+ const demographics = require('./demographics');
2
+ const safety = require('./safety');
3
+
4
+ async function getLocationProfile(zip) {
5
+ if (!/^\d{5}$/.test(zip)) return { error: 'Valid 5-digit ZIP code required' };
6
+
7
+ const [demoData, safetyData] = await Promise.allSettled([
8
+ demographics.getByZip(zip),
9
+ safety.getSafetyProfile(zip),
10
+ ]);
11
+
12
+ const demo = demoData.status === 'fulfilled' ? demoData.value : null;
13
+ const safe = safetyData.status === 'fulfilled' ? safetyData.value : null;
14
+
15
+ const population = demo ? demo.population : null;
16
+ const medianIncome = demo ? demo.medianIncome : null;
17
+ const safetyScore = safe ? safe.safetyScore : null;
18
+
19
+ const parts = [`ZIP ${zip}`];
20
+ if (population) parts.push(`population ${population.toLocaleString()}`);
21
+ if (medianIncome && medianIncome > 0) parts.push(`median income $${medianIncome.toLocaleString()}`);
22
+ if (safetyScore !== null) parts.push(`safety score ${safetyScore}/100`);
23
+ const summary = parts.join(', ');
24
+
25
+ return {
26
+ domain: 'location',
27
+ source: 'Census + EPA + CMS',
28
+ source_url: 'https://api.openprimitive.com',
29
+ freshness: new Date().toISOString(),
30
+ zip,
31
+ demographics: {
32
+ population,
33
+ medianIncome,
34
+ povertyRate: demo ? demo.povertyRate : null,
35
+ collegeRate: demo ? demo.collegeRate : null,
36
+ medianHomeValue: demo ? demo.medianHomeValue : null,
37
+ medianRent: demo ? demo.medianRent : null,
38
+ },
39
+ safety: {
40
+ score: safetyScore,
41
+ water: {
42
+ systemCount: safe ? safe.water.systemCount : null,
43
+ healthViolations: safe ? safe.water.healthViolations : null,
44
+ },
45
+ hospitals: {
46
+ count: safe ? safe.hospitals.count : null,
47
+ avgRating: safe ? safe.hospitals.averageRating : null,
48
+ },
49
+ },
50
+ summary,
51
+ };
52
+ }
53
+
54
+ module.exports = { getLocationProfile };
@@ -0,0 +1,91 @@
1
+ const fetch = require('node-fetch');
2
+
3
+ const USDA_BASE = 'https://api.nal.usda.gov/fdc/v1';
4
+ const API_KEY = process.env.USDA_API_KEY || 'DEMO_KEY';
5
+
6
+ const KEY_NUTRIENTS = [
7
+ { id: 1008, name: 'Calories', unit: 'kcal' },
8
+ { id: 1003, name: 'Protein', unit: 'g' },
9
+ { id: 1004, name: 'Total Fat', unit: 'g' },
10
+ { id: 1005, name: 'Carbohydrates', unit: 'g' },
11
+ { id: 1079, name: 'Fiber', unit: 'g' },
12
+ { id: 2000, name: 'Sugar', unit: 'g' },
13
+ { id: 1093, name: 'Sodium', unit: 'mg' },
14
+ ];
15
+
16
+ async function fetchWithTimeout(url, ms = 10000) {
17
+ const ctrl = new AbortController();
18
+ const id = setTimeout(() => ctrl.abort(), ms);
19
+ try {
20
+ const res = await fetch(url, { signal: ctrl.signal });
21
+ clearTimeout(id);
22
+ if (!res.ok) return null;
23
+ return await res.json();
24
+ } catch {
25
+ clearTimeout(id);
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function extractNutrients(foodNutrients) {
31
+ if (!foodNutrients) return [];
32
+ return KEY_NUTRIENTS.map(kn => {
33
+ const match = foodNutrients.find(fn =>
34
+ (fn.nutrientId === kn.id) || (fn.nutrient && fn.nutrient.id === kn.id)
35
+ );
36
+ return {
37
+ name: kn.name,
38
+ amount: match ? (match.value != null ? match.value : match.amount) || 0 : 0,
39
+ unit: kn.unit,
40
+ };
41
+ });
42
+ }
43
+
44
+ async function searchFood(query) {
45
+ if (!query) return { error: 'query is required' };
46
+ const encoded = encodeURIComponent(query);
47
+ const url = `${USDA_BASE}/foods/search?query=${encoded}&pageSize=10&api_key=${API_KEY}`;
48
+ const data = await fetchWithTimeout(url);
49
+ if (!data) return { error: 'USDA FoodData Central API unavailable' };
50
+ return {
51
+ domain: 'nutrition',
52
+ source: 'USDA FoodData Central',
53
+ source_url: 'https://fdc.nal.usda.gov',
54
+ freshness: new Date().toISOString(),
55
+ query,
56
+ total: data.totalHits || 0,
57
+ results: (data.foods || []).map(f => ({
58
+ fdcId: f.fdcId,
59
+ description: f.description,
60
+ brandName: f.brandName || null,
61
+ dataType: f.dataType,
62
+ nutrients: extractNutrients(f.foodNutrients),
63
+ })),
64
+ };
65
+ }
66
+
67
+ async function getFood(fdcId) {
68
+ if (!fdcId) return { error: 'fdcId is required' };
69
+ const url = `${USDA_BASE}/food/${fdcId}?api_key=${API_KEY}`;
70
+ const data = await fetchWithTimeout(url);
71
+ if (!data) return { error: 'USDA FoodData Central API unavailable' };
72
+ return {
73
+ domain: 'nutrition',
74
+ source: 'USDA FoodData Central',
75
+ source_url: 'https://fdc.nal.usda.gov',
76
+ freshness: new Date().toISOString(),
77
+ fdcId: data.fdcId,
78
+ description: data.description,
79
+ brandName: data.brandName || null,
80
+ dataType: data.dataType,
81
+ servingSize: data.servingSize || null,
82
+ servingSizeUnit: data.servingSizeUnit || null,
83
+ nutrients: (data.foodNutrients || []).map(fn => ({
84
+ name: fn.nutrient ? fn.nutrient.name : fn.name,
85
+ amount: fn.amount != null ? fn.amount : 0,
86
+ unit: fn.nutrient ? fn.nutrient.unitName : fn.unitName,
87
+ })),
88
+ };
89
+ }
90
+
91
+ module.exports = { searchFood, getFood };