1
2
3
4
5
6
7
8import { Actor, log } from 'apify';
9
10const USER_AGENT = 'foundry-actor/0.1 (Apify Actor; polite client; aviationweather.gov data)';
11const MIN_INTERVAL_MS = 1000;
12const MAX_STATIONS = 100;
13
14
15
16const MAX_PIREP_STATIONS = 25;
17let lastRequestAt = 0;
18
19async function politeGetJson(url, attempt = 1) {
20 const wait = lastRequestAt + MIN_INTERVAL_MS - Date.now();
21 if (wait > 0) await new Promise((r) => setTimeout(r, wait));
22 lastRequestAt = Date.now();
23 const res = await fetch(url, { headers: { 'user-agent': USER_AGENT, accept: 'application/json' } });
24 if (res.status === 429 || res.status >= 500) {
25 if (attempt >= 4) throw new Error(`HTTP ${res.status} after ${attempt} attempts: ${url}`);
26 await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
27 return politeGetJson(url, attempt + 1);
28 }
29
30
31 if (res.status === 204) return [];
32 if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}: ${url}`);
33 const text = await res.text();
34 return text ? JSON.parse(text) : [];
35}
36
37
38
39function remainingBudget() {
40 try {
41 const n = Actor.getChargingManager().calculateMaxEventChargeCountWithinLimit('apify-default-dataset-item');
42 return Number.isFinite(n) ? n : Infinity;
43 } catch {
44 return Infinity;
45 }
46}
47
48function toIso(unixSeconds) {
49 return Number.isFinite(unixSeconds) ? new Date(unixSeconds * 1000).toISOString() : null;
50}
51
52
53
54function baseRecord() {
55 return {
56 icaoId: null,
57 stationName: null,
58 reportType: null,
59 issuedAt: null,
60 validFrom: null,
61 validTo: null,
62 rawText: null,
63 tempC: null,
64 dewpointC: null,
65 windDirDeg: null,
66 windSpeedKt: null,
67 windGustKt: null,
68 visibilitySm: null,
69 altimeterHpa: null,
70 flightCategory: null,
71 clouds: null,
72 forecastPeriods: null,
73 latitude: null,
74 longitude: null,
75 elevationM: null,
76 aircraftType: null,
77 flightLevelFt: null,
78 flightPhase: null,
79 turbulence: null,
80 icing: null,
81 hazardType: null,
82 qualifier: null,
83 altitudeBaseFt: null,
84 altitudeTopFt: null,
85 movementDir: null,
86 movementSpeedKt: null,
87 firId: null,
88 advisoryId: null,
89 subType: null,
90 forecastHour: null,
91 area: null,
92 };
93}
94
95function mapMetar(r) {
96 return {
97 ...baseRecord(),
98 icaoId: r.icaoId ?? null,
99 stationName: r.name ?? null,
100 reportType: 'metar',
101 issuedAt: r.reportTime ?? null,
102 rawText: r.rawOb ?? null,
103 tempC: r.temp ?? null,
104 dewpointC: r.dewp ?? null,
105 windDirDeg: r.wdir ?? null,
106 windSpeedKt: r.wspd ?? null,
107 windGustKt: r.wgst ?? null,
108 visibilitySm: r.visib ?? null,
109 altimeterHpa: r.altim ?? null,
110 flightCategory: r.fltCat ?? null,
111 clouds: r.clouds ?? null,
112 latitude: r.lat ?? null,
113 longitude: r.lon ?? null,
114 elevationM: r.elev ?? null,
115 };
116}
117
118function mapTaf(r) {
119 return {
120 ...baseRecord(),
121 icaoId: r.icaoId ?? null,
122 stationName: r.name ?? null,
123 reportType: 'taf',
124 issuedAt: r.issueTime ?? null,
125 validFrom: toIso(r.validTimeFrom),
126 validTo: toIso(r.validTimeTo),
127 rawText: r.rawTAF ?? null,
128 forecastPeriods: (r.fcsts ?? []).map((f) => ({
129 from: toIso(f.timeFrom),
130 to: toIso(f.timeTo),
131 windDirDeg: f.wdir ?? null,
132 windSpeedKt: f.wspd ?? null,
133 windGustKt: f.wgst ?? null,
134 visibilitySm: f.visib ?? null,
135 altimeterHpa: f.altim ?? null,
136 weather: f.wxString ?? null,
137 clouds: f.clouds ?? null,
138 })),
139 latitude: r.lat ?? null,
140 longitude: r.lon ?? null,
141 elevationM: r.elev ?? null,
142 };
143}
144
145
146
147function pirepLayers(r, prefix, keys) {
148 const layers = [];
149 for (const n of [1, 2]) {
150 const [baseK, topK, intK, typeK, freqK] = keys.map((k) => `${prefix}${k}${n}`);
151 const intensity = r[intK];
152 if (!intensity) continue;
153 const layer = { base: r[baseK] ?? null, top: r[topK] ?? null, intensity, type: r[typeK] || null };
154 if (freqK) layer.frequency = r[freqK] || null;
155 layers.push(layer);
156 }
157 return layers.length ? layers : null;
158}
159
160function mapPirep(r) {
161 return {
162 ...baseRecord(),
163 icaoId: r.icaoId ?? null,
164 reportType: 'pirep',
165 issuedAt: toIso(r.obsTime),
166 rawText: r.rawOb ?? null,
167 tempC: r.temp ?? null,
168 windDirDeg: r.wdir ?? null,
169 windSpeedKt: r.wspd ?? null,
170 visibilitySm: r.visib ?? null,
171 clouds: r.clouds ?? null,
172 latitude: r.lat ?? null,
173 longitude: r.lon ?? null,
174 aircraftType: r.acType ?? null,
175 flightLevelFt: r.fltLvl != null && Number.isFinite(Number(r.fltLvl)) ? Number(r.fltLvl) * 100 : null,
176 flightPhase: r.fltLvlType ?? null,
177 turbulence: pirepLayers(r, 'tb', ['Bas', 'Top', 'Int', 'Type', 'Freq']),
178 icing: pirepLayers(r, 'icg', ['Bas', 'Top', 'Int', 'Type']),
179 qualifier: r.wxString || null,
180 };
181}
182
183
184
185
186function mapSigmet(r) {
187 return {
188 ...baseRecord(),
189 icaoId: r.icaoId ?? null,
190 stationName: r.firName ?? null,
191 reportType: 'sigmet',
192 issuedAt: r.issueTime ?? null,
193 validFrom: toIso(r.validTimeFrom),
194 validTo: toIso(r.validTimeTo),
195 rawText: r.rawSigmet ?? null,
196 hazardType: r.hazard ?? null,
197 qualifier: r.qualifier ?? null,
198 altitudeBaseFt: r.base ?? null,
199 altitudeTopFt: r.top ?? null,
200 movementDir: r.dir ?? null,
201 movementSpeedKt: r.spd != null && Number.isFinite(Number(r.spd)) ? Number(r.spd) : null,
202 firId: r.firId ?? null,
203 advisoryId: r.seriesId ?? null,
204 subType: r.sigmetType ?? null,
205 area: r.geom ? { geom: r.geom, coords: r.coords ?? [] } : null,
206 };
207}
208
209function mapGairmet(r) {
210 return {
211 ...baseRecord(),
212 reportType: 'gairmet',
213 issuedAt: toIso(r.issueTime),
214 validFrom: r.validTime ?? null,
215 validTo: toIso(r.expireTime),
216 hazardType: r.hazard ?? null,
217 qualifier: r.severity ?? r.due_to ?? null,
218 altitudeBaseFt: r.base ?? null,
219 altitudeTopFt: r.top ?? null,
220 advisoryId: r.tag ?? null,
221 subType: r.product ?? null,
222 forecastHour: r.forecastHour ?? null,
223 area: r.geom ? { geom: r.geom, coords: r.coords ?? [] } : null,
224 };
225}
226
227await Actor.init();
228
229const input = (await Actor.getInput()) ?? {};
230const rawStations = String(input.stations ?? 'KJFK,KLAX,KORD').trim();
231const includeTaf = input.includeTaf ?? true;
232const hoursBack = Number(input.hoursBack ?? 0);
233const includePireps = input.includePireps ?? false;
234const pirepRadiusSm = Number(input.pirepRadiusSm ?? 200);
235const pirepHoursBack = Number(input.pirepHoursBack ?? 3);
236const includeHazards = input.includeHazards ?? false;
237if (!Number.isInteger(hoursBack) || hoursBack < 0 || hoursBack > 168) {
238 throw new Error('Input "hoursBack" must be a whole number from 0 (latest report only) to 168 (7 days).');
239}
240if (!Number.isFinite(pirepRadiusSm) || pirepRadiusSm < 10 || pirepRadiusSm > 500) {
241 throw new Error('Input "pirepRadiusSm" must be a number from 10 to 500.');
242}
243if (!Number.isInteger(pirepHoursBack) || pirepHoursBack < 1 || pirepHoursBack > 24) {
244 throw new Error('Input "pirepHoursBack" must be a whole number from 1 to 24.');
245}
246if (!rawStations) throw new Error('Input "stations" must not be empty.');
247
248const stations = [...new Set(
249 rawStations.split(/[,\s]+/).map((s) => s.trim().toUpperCase()).filter(Boolean),
250)].slice(0, MAX_STATIONS);
251
252const invalid = stations.filter((s) => !/^[A-Z0-9]{3,4}$/.test(s));
253if (invalid.length) throw new Error(`Invalid ICAO/station code(s): ${invalid.join(', ')}`);
254if (stations.length === 0) throw new Error('Input "stations" must contain at least one ICAO code.');
255
256const ids = stations.join(',');
257let metarRecords = [];
258let tafRecords = [];
259let pirepRecords = [];
260let hazardRecords = [];
261
262
263const hoursParam = hoursBack > 0 ? `&hours=${hoursBack}` : '';
264const metarData = await politeGetJson(`https://aviationweather.gov/api/data/metar?ids=${encodeURIComponent(ids)}&format=json${hoursParam}`);
265metarRecords = metarData.map(mapMetar);
266
267if (includeTaf) {
268 const tafData = await politeGetJson(`https://aviationweather.gov/api/data/taf?ids=${encodeURIComponent(ids)}&format=json`);
269 tafRecords = tafData.map(mapTaf);
270}
271
272if (includePireps) {
273
274
275 const pirepStations = stations.slice(0, MAX_PIREP_STATIONS);
276 if (stations.length > MAX_PIREP_STATIONS) {
277 log.info(`PIREPs are limited to the first ${MAX_PIREP_STATIONS} stations; skipping the rest.`);
278 }
279 for (const station of pirepStations) {
280 const url = `https://aviationweather.gov/api/data/pirep?format=json&id=${encodeURIComponent(station)}&distance=${pirepRadiusSm}&age=${pirepHoursBack}`;
281 const data = await politeGetJson(url);
282 pirepRecords = pirepRecords.concat(data.map(mapPirep));
283 }
284}
285
286if (includeHazards) {
287
288
289
290 const sigmetData = await politeGetJson('https://aviationweather.gov/api/data/sigmet?format=json');
291 const gairmetData = await politeGetJson('https://aviationweather.gov/api/data/gairmet?format=json');
292 hazardRecords = sigmetData.map(mapSigmet).concat(gairmetData.map(mapGairmet));
293}
294
295
296
297const records = [...tafRecords, ...metarRecords, ...pirepRecords, ...hazardRecords];
298if (records.length === 0) log.info(`No weather data found for: ${ids}`);
299
300const budget = remainingBudget();
301const batch = records.slice(0, budget);
302let saved = 0;
303if (batch.length > 0) {
304 await Actor.pushData(batch);
305 saved = batch.length;
306}
307if (batch.length < records.length) log.info('Reached the maximum charge set for this run; stopping.');
308
309log.info(`Saved ${saved} items for stations: ${ids}`);
310await Actor.exit();