atmosx-nwws-parser 1.0.19 → 1.0.22

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.
Files changed (44) hide show
  1. package/README.md +182 -64
  2. package/dist/cjs/index.cjs +3799 -0
  3. package/dist/esm/index.mjs +3757 -0
  4. package/package.json +49 -37
  5. package/src/bootstrap.ts +196 -0
  6. package/src/database.ts +148 -0
  7. package/src/dictionaries/awips.ts +342 -0
  8. package/src/dictionaries/events.ts +142 -0
  9. package/src/dictionaries/icao.ts +237 -0
  10. package/src/dictionaries/offshore.ts +12 -0
  11. package/src/dictionaries/signatures.ts +107 -0
  12. package/src/eas.ts +493 -0
  13. package/src/index.ts +229 -0
  14. package/src/parsers/events/api.ts +151 -0
  15. package/src/parsers/events/cap.ts +138 -0
  16. package/src/parsers/events/text.ts +106 -0
  17. package/src/parsers/events/ugc.ts +109 -0
  18. package/src/parsers/events/vtec.ts +78 -0
  19. package/src/parsers/events.ts +367 -0
  20. package/src/parsers/hvtec.ts +46 -0
  21. package/src/parsers/pvtec.ts +71 -0
  22. package/src/parsers/stanza.ts +132 -0
  23. package/src/parsers/text.ts +166 -0
  24. package/src/parsers/ugc.ts +197 -0
  25. package/src/types.ts +251 -0
  26. package/src/utils.ts +314 -0
  27. package/src/xmpp.ts +144 -0
  28. package/test.js +58 -34
  29. package/tsconfig.json +12 -5
  30. package/tsup.config.ts +14 -0
  31. package/bootstrap.ts +0 -122
  32. package/dist/bootstrap.js +0 -153
  33. package/dist/src/events.js +0 -585
  34. package/dist/src/helper.js +0 -463
  35. package/dist/src/stanza.js +0 -147
  36. package/dist/src/text-parser.js +0 -119
  37. package/dist/src/ugc.js +0 -214
  38. package/dist/src/vtec.js +0 -125
  39. package/src/events.ts +0 -394
  40. package/src/helper.ts +0 -298
  41. package/src/stanza.ts +0 -102
  42. package/src/text-parser.ts +0 -120
  43. package/src/ugc.ts +0 -122
  44. package/src/vtec.ts +0 -99
@@ -0,0 +1,132 @@
1
+ /*
2
+ _ _ __ __
3
+ /\ | | | | (_) \ \ / /
4
+ / \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
5
+ / /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
6
+ / ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
7
+ /_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
8
+ | |
9
+ |_|
10
+
11
+ Written by: KiyoWx (k3yomi)
12
+ */
13
+
14
+
15
+ import * as loader from '../bootstrap';
16
+ import * as types from '../types';
17
+
18
+ export class StanzaParser {
19
+
20
+ /**
21
+ * @function validate
22
+ * @description
23
+ * Validates and parses a stanza message, extracting its attributes and metadata.
24
+ * Handles both raw message strings (for debug/testing) and actual stanza objects.
25
+ * Determines whether the message is a CAP alert, contains VTEC codes, or contains UGCs,
26
+ * and identifies the AWIPS product type and prefix.
27
+ *
28
+ * @static
29
+ * @param {any} stanza
30
+ * @param {boolean | types.StanzaAttributes} [isDebug=false]
31
+ * @returns {{
32
+ * message: string;
33
+ * attributes: types.StanzaAttributes;
34
+ * isCap: boolean,
35
+ * isPVtec: boolean;
36
+ * isCapDescription: boolean;
37
+ * awipsType: Record<string, string>;
38
+ * isApi: boolean;
39
+ * ignore: boolean;
40
+ * isUGC?: boolean;
41
+ * }}
42
+ */
43
+ public static validate(stanza: any, isDebug: boolean | types.StanzaAttributes = false): { message: string; attributes: types.StanzaAttributes; isCap: any; isPVtec: boolean; isCapDescription: any; awipsType: any; isApi: boolean; ignore: boolean; isUGC?: boolean; } {
44
+ if (isDebug !== false) {
45
+ const vTypes = isDebug as types.StanzaAttributes;
46
+ const message = stanza;
47
+ const attributes = vTypes;
48
+ const isCap = vTypes.isCap ?? message.includes(`<?xml`);
49
+ const isCapDescription = message.includes(`<areaDesc>`);
50
+ const isPVtec = message.match(loader.definitions.regular_expressions.pvtec) != null;
51
+ const isUGC = message.match(loader.definitions.regular_expressions.ugc1) != null;
52
+ const awipsType = this.getType(attributes);
53
+ return { message, attributes, isCap, isPVtec, isUGC, isCapDescription, awipsType: awipsType, isApi: false, ignore: false };
54
+ }
55
+ if (stanza.is(`message`)) {
56
+ let cb = stanza.getChild(`x`)
57
+ if (cb && cb.children) {
58
+ let message = unescape(cb.children[0])
59
+ let attributes = cb.attrs
60
+ if (attributes.awipsid && attributes.awipsid.length > 1) {
61
+ const isCap = message.includes(`<?xml`);
62
+ const isCapDescription = message.includes(`<areaDesc>`);
63
+ const isPVtec = message.match(loader.definitions.regular_expressions.pvtec) != null;
64
+ const isUGC = message.match(loader.definitions.regular_expressions.ugc1) != null;
65
+ const awipsType = this.getType(attributes);
66
+ this.cache(message, {attributes, isCap, isPVtec, awipsType });
67
+ return { message, attributes, isCap, isPVtec, isUGC, isCapDescription, awipsType: awipsType, isApi: false, ignore: false };
68
+ }
69
+ }
70
+ }
71
+ return { message: null, attributes: null, isApi: null, isCap: null, isPVtec: null, isUGC: null, isCapDescription: null, awipsType: null, ignore: true };
72
+ }
73
+
74
+ /**
75
+ * @function getType
76
+ * @description
77
+ * Determines the AWIPS product type and prefix from a stanza's attributes.
78
+ * Returns a default type of 'XX' if the attributes are missing or the AWIPS ID
79
+ * does not match any known definitions.
80
+ *
81
+ * @private
82
+ * @static
83
+ * @param {unknown} attributes
84
+ * @returns {Record<string, string>}
85
+ */
86
+ private static getType(attributes: unknown): Record<string, string> {
87
+ const attrs = attributes as types.StanzaAttributesType | undefined;
88
+ if (!attrs?.awipsid) return { type: 'XX', prefix: 'XX' };
89
+ const awipsDefs = loader.definitions.awips;
90
+ for (const [prefix, type] of Object.entries(awipsDefs)) {
91
+ if (attrs.awipsid.startsWith(prefix)) {
92
+ return { type, prefix };
93
+ }
94
+ }
95
+ return { type: 'XX', prefix: 'XX' };
96
+ }
97
+
98
+ /**
99
+ * @function cache
100
+ * @description
101
+ * Saves a compiled stanza message to the local cache directory.
102
+ * Ensures the message contains "STANZA ATTRIBUTES..." metadata and timestamps,
103
+ * and appends the formatted entry to both a category-specific file and a general cache file.
104
+ *
105
+ * @private
106
+ * @static
107
+ * @async
108
+ * @param {unknown} compiled
109
+ * @returns {Promise<void>}
110
+ */
111
+ private static async cache(message: string, compiled: unknown): Promise<void> {
112
+ if (!compiled) return;
113
+ const data = compiled as types.StanzaCompiled;
114
+ const settings = loader.settings as types.ClientSettingsTypes;
115
+ const { fs, path } = loader.packages;
116
+ if (!message || !settings.noaa_weather_wire_service_settings.cache.directory) return;
117
+ const cacheDir = settings.noaa_weather_wire_service_settings.cache.directory;
118
+ if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
119
+ const prefix = `category-${data.awipsType.prefix}-${data.awipsType.type}s`;
120
+ const suffix = `${data.isCap ? 'cap' : 'raw'}${data.isPVtec ? '-vtec' : ''}`;
121
+ const categoryFile = path.join(cacheDir, `${prefix}-${suffix}.bin`);
122
+ const cacheFile = path.join(cacheDir, `cache-${suffix}.bin`);
123
+ const entry = `[SoF]\nSTANZA ATTRIBUTES...${JSON.stringify(compiled)}\n[EoF]\n${message}`;
124
+ await Promise.all([
125
+ fs.promises.appendFile(categoryFile, entry, 'utf8'),
126
+ fs.promises.appendFile(cacheFile, entry, 'utf8'),
127
+ ]);
128
+ }
129
+
130
+ }
131
+
132
+ export default StanzaParser;
@@ -0,0 +1,166 @@
1
+ /*
2
+ _ _ __ __
3
+ /\ | | | | (_) \ \ / /
4
+ / \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
5
+ / /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
6
+ / ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
7
+ /_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
8
+ | |
9
+ |_|
10
+
11
+ Written by: KiyoWx (k3yomi)
12
+ */
13
+
14
+ import * as loader from '../bootstrap';
15
+
16
+ export class TextParser {
17
+
18
+ /**
19
+ * @function textProductToString
20
+ * @description
21
+ * Searches a text product message for a line containing a specific value,
22
+ * extracts the substring immediately following that value, and optionally
23
+ * removes additional specified strings. Cleans up the extracted string by
24
+ * trimming whitespace and removing any remaining occurrences of the search
25
+ * value or '<' characters.
26
+ *
27
+ * @static
28
+ * @param {string} message
29
+ * @param {string} value
30
+ * @param {string[]} [removal=[]]
31
+ * @returns {string | null}
32
+ */
33
+ public static textProductToString(message: string,value: string,removal: string[] = []): string | null {
34
+ const lines = message.split('\n');
35
+ for (const line of lines) {
36
+ if (line.includes(value)) {
37
+ let result = line.slice(line.indexOf(value) + value.length).trim();
38
+ for (const str of removal) { result = result.split(str).join(''); }
39
+ result = result.replace(value, '').replace('<', '').trim();
40
+ return result || null;
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * @function textProductToPolygon
48
+ * @description
49
+ * Parses a text product message to extract polygon coordinates based on
50
+ * LAT...LON data. Coordinates are converted to [latitude, longitude] pairs
51
+ * with longitude negated (assumes Western Hemisphere). If the polygon has
52
+ * more than two points, the first point is repeated at the end to close it.
53
+ *
54
+ * @static
55
+ * @param {string} message
56
+ * @returns {[number, number][]}
57
+ */
58
+ public static textProductToPolygon(message: string): [number, number][] {
59
+ const coordinates: [number, number][] = [];
60
+ const latLonMatch = message.match(/LAT\.{3}LON\s+([\d\s]+)/i);
61
+ if (!latLonMatch || !latLonMatch[1]) return coordinates;
62
+ const coordStrings = latLonMatch[1].replace(/\n/g, ' ').trim().split(/\s+/);
63
+ for (let i = 0; i < coordStrings.length - 1; i += 2) {
64
+ const lat = parseFloat(coordStrings[i]) / 100;
65
+ const lon = -parseFloat(coordStrings[i + 1]) / 100;
66
+ if (!isNaN(lat) && !isNaN(lon)) { coordinates.push([lon, lat]); }
67
+ }
68
+ if (coordinates.length > 2) { coordinates.push(coordinates[0]); }
69
+ return coordinates;
70
+ }
71
+
72
+ /**
73
+ * @function textProductToDescription
74
+ * @description
75
+ * Extracts a clean description portion from a text product message, optionally
76
+ * removing a handle and any extra metadata such as "STANZA ATTRIBUTES...".
77
+ * Also trims and normalizes whitespace.
78
+ *
79
+ * @static
80
+ * @param {string} message
81
+ * @param {string | null} [handle=null]
82
+ * @returns {string}
83
+ */
84
+ public static textProductToDescription(message: string, handle: string = null): string {
85
+ const original = message;
86
+ const discoveredDates = Array.from(message.matchAll(loader.definitions.regular_expressions.dateline));
87
+ if (discoveredDates.length) {
88
+ const lastMatch = discoveredDates[discoveredDates.length - 1][0];
89
+ const startIdx = message.lastIndexOf(lastMatch);
90
+ if (startIdx !== -1) {
91
+ const endIdx = message.indexOf('&&', startIdx);
92
+ message = message.substring(startIdx + lastMatch.length, endIdx !== -1 ? endIdx : undefined).trimStart();
93
+ if (message.startsWith('/')) message = message.slice(1).trimStart();
94
+ if (handle && message.includes(handle)) {
95
+ const handleIdx = message.indexOf(handle);
96
+ message = message.substring(handleIdx + handle.length).trimStart();
97
+ if (message.startsWith('/')) message = message.slice(1).trimStart();
98
+ }
99
+ }
100
+ } else if (handle) {
101
+ const handleIdx = message.indexOf(handle);
102
+ if (handleIdx !== -1) {
103
+ let afterHandle = message.substring(handleIdx + handle.length).trimStart();
104
+ if (afterHandle.startsWith('/')) afterHandle = afterHandle.slice(1).trimStart();
105
+ const latEnd = afterHandle.indexOf('&&')
106
+ message = latEnd !== -1 ? afterHandle.substring(0, latEnd).trim() : afterHandle.trim();
107
+ }
108
+ }
109
+ return message.replace(/\s+/g, ' ').trim().startsWith('STANZA ATTRIBUTES...') ? original : message.split('STANZA ATTRIBUTES...')[0].trim();
110
+ }
111
+
112
+ /**
113
+ * @function getXmlValues
114
+ * @description
115
+ * Recursively extracts specified values from a parsed XML-like object.
116
+ * Searches both object keys and array items for matching keys (case-insensitive)
117
+ * and returns the corresponding values. If multiple unique values are found for
118
+ * a key, an array is returned; if one value is found, it returns that value;
119
+ * if none are found, returns `null`.
120
+ *
121
+ * @static
122
+ * @param {any} parsed
123
+ * @param {string[]} valuesToExtract
124
+ * @returns {Record<string, string | string[] | null>}
125
+ */
126
+ public static getXmlValues(parsed: any, valuesToExtract: string[]): Record<string, string> {
127
+ const extracted: Record<string, any> = {};
128
+ const findValueByKey = (obj: any, searchKey: string) => {
129
+ const results = [];
130
+ if (obj === null || typeof obj !== 'object') {
131
+ return results;
132
+ }
133
+ const searchKeyLower = searchKey.toLowerCase();
134
+ for (const key in obj) {
135
+ if (obj.hasOwnProperty(key) && key.toLowerCase() === searchKeyLower) {
136
+ results.push(obj[key]);
137
+ }
138
+ }
139
+ if (Array.isArray(obj)) {
140
+ for (const item of obj) {
141
+ if (item.valueName && item.valueName.toLowerCase() === searchKeyLower && item.value !== undefined) {
142
+ results.push(item.value);
143
+ }
144
+ const nestedResults = findValueByKey(item, searchKey);
145
+ results.push(...nestedResults);
146
+ }
147
+ }
148
+ for (const key in obj) {
149
+ if (obj.hasOwnProperty(key)) {
150
+ const nestedResults = findValueByKey(obj[key], searchKey);
151
+ results.push(...nestedResults);
152
+ }
153
+ }
154
+ return results;
155
+ };
156
+ for (const key of valuesToExtract) {
157
+ const values = findValueByKey(parsed.alert, key);
158
+ const uniqueValues = [...new Set(values)];
159
+ extracted[key] = uniqueValues.length === 0 ? null : (uniqueValues.length === 1 ? uniqueValues[0] : uniqueValues);
160
+ }
161
+ return extracted;
162
+ }
163
+
164
+ }
165
+
166
+ export default TextParser;
@@ -0,0 +1,197 @@
1
+ /*
2
+ _ _ __ __
3
+ /\ | | | | (_) \ \ / /
4
+ / \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
5
+ / /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
6
+ / ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
7
+ /_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
8
+ | |
9
+ |_|
10
+
11
+ Written by: KiyoWx (k3yomi)
12
+ */
13
+
14
+ import * as loader from '../bootstrap';
15
+ import * as types from '../types';
16
+
17
+
18
+ export class UGCParser {
19
+
20
+ /**
21
+ * @function ugcExtractor
22
+ * @description
23
+ * Extracts UGC (Universal Geographic Code) information from a message.
24
+ * This includes parsing the header, resolving zones, calculating the expiry
25
+ * date, and retrieving associated location names from the database.
26
+ *
27
+ * @static
28
+ * @async
29
+ * @param {string} message
30
+ * @returns {Promise<types.UGCEntry | null>}
31
+ */
32
+ public static async ugcExtractor(message: string): Promise<types.UGCEntry | null> {
33
+ const header = this.getHeader(message);
34
+ if (!header) return null;
35
+ const zones = this.getZones(header);
36
+ if (zones.length === 0) return null;
37
+ const expiry = this.getExpiry(message);
38
+ const locations = await this.getLocations(zones);
39
+ return {
40
+ zones: zones,
41
+ locations: locations,
42
+ expiry: expiry
43
+ };
44
+ }
45
+
46
+ /**
47
+ * @function getHeader
48
+ * @description
49
+ * Extracts the UGC header from a message by locating patterns defined in
50
+ * `ugc1` and `ugc2` regular expressions. Removes all whitespace and the
51
+ * trailing character from the matched header.
52
+ *
53
+ * @static
54
+ * @param {string} message
55
+ * @returns {string | null}
56
+ */
57
+ public static getHeader(message: string): string | null {
58
+ const start = message.search(loader.definitions.regular_expressions.ugc1);
59
+ const subMessage = message.substring(start);
60
+ const end = subMessage.search(loader.definitions.regular_expressions.ugc2);
61
+ const full = subMessage.substring(0, end).replace(/\s+/g, '').slice(0, -1);
62
+ return full || null;
63
+ }
64
+
65
+ /**
66
+ * @function getExpiry
67
+ * @description
68
+ * Extracts an expiration date from a message using the UGC3 format.
69
+ * The function parses day, hour, and minute from the message and constructs
70
+ * a Date object in the current month and year. Returns `null` if no valid
71
+ * expiration is found.
72
+ *
73
+ * @static
74
+ * @param {string} message
75
+ * @returns {Date | null}
76
+ */
77
+ public static getExpiry(message: string): Date | null {
78
+ const start = message.match(loader.definitions.regular_expressions.ugc3);
79
+ const day = parseInt(start[0].substring(0, 2), 10);
80
+ const hour = parseInt(start[0].substring(2, 4), 10);
81
+ const minute = parseInt(start[0].substring(4, 6), 10);
82
+ const now = new Date();
83
+ const expires = new Date(now.getUTCFullYear(), now.getUTCMonth(), day, hour, minute, 0);
84
+ return expires;
85
+ }
86
+
87
+ /**
88
+ * @function getLocations
89
+ * @description
90
+ * Retrieves human-readable location names for an array of zone identifiers
91
+ * from the shapefiles database. If a zone is not found, the zone ID itself
92
+ * is returned. Duplicate locations are removed and the result is sorted.
93
+ *
94
+ * @static
95
+ * @async
96
+ * @param {string[]} zones
97
+ * @returns {Promise<string[]>}
98
+ */
99
+ public static async getLocations(zones: string[]): Promise<string[]> {
100
+ const uniqueZones = Array.from(new Set(zones.map(z => z.trim())));
101
+ const placeholders = uniqueZones.map(() => '?').join(',');
102
+ const rows = await loader.cache.db.prepare(
103
+ `SELECT id, location FROM shapefiles WHERE id IN (${placeholders})`
104
+ ).all(...uniqueZones);
105
+ const locationMap = new Map<string, string>();
106
+ for (const row of rows) { locationMap.set(row.id, row.location) }
107
+ const locations = uniqueZones.map(id => locationMap.get(id) ?? id);
108
+ return locations.sort();
109
+ }
110
+
111
+ /**
112
+ * @function getCoordinates
113
+ * @description
114
+ * Retrieves geographic coordinates for an array of zone identifiers
115
+ * from the shapefiles database. Returns the coordinates of the first
116
+ * polygon found for any matching zone.
117
+ *
118
+ * @static
119
+ * @param {string[]} zones
120
+ * @returns {[number, number][]}
121
+ */
122
+ public static getCoordinates(zones: string[]): any | null {
123
+ const polygons: any[] = [];
124
+ for (const zone of zones.map(z => z.trim())) {
125
+ const row = loader.cache.db.prepare(`SELECT geometry FROM shapefiles WHERE id = ?`).get(zone);
126
+ if (row !== undefined) {
127
+ const geometry = JSON.parse(row.geometry);
128
+ if (geometry?.type === 'Polygon') {
129
+ polygons.push({
130
+ type: "Feature",
131
+ geometry,
132
+ properties: {}
133
+ });
134
+ }
135
+ }
136
+ }
137
+ if (polygons.length === 0) return null;
138
+ let merged = polygons[0];
139
+ for (let i = 1; i < polygons.length; i++) {
140
+ merged = loader.packages.turf.union(merged, polygons[i]);
141
+ }
142
+ const outerRing = merged.geometry.type === "Polygon"
143
+ ? merged.geometry.coordinates[0]
144
+ : merged.geometry.coordinates[0][0];
145
+ const skip = loader.settings.global_settings.shapefile_skip;
146
+ const skippedRing = outerRing.filter((_: any, index: number) => index % skip === 0);
147
+ return [skippedRing]
148
+ }
149
+
150
+ /**
151
+ * @function getZones
152
+ * @description
153
+ * Parses a UGC header string and returns an array of individual zone
154
+ * identifiers. Handles ranges indicated with `>` and preserves the
155
+ * state and format prefixes.
156
+ *
157
+ * @static
158
+ * @param {string} header
159
+ * @returns {string[]}
160
+ */
161
+ public static getZones(header: string): string[] {
162
+ const ugcSplit = header.split('-');
163
+ const zones: string[] = [];
164
+ let state = ugcSplit[0].substring(0, 2);
165
+ const format = ugcSplit[0].substring(2, 3);
166
+ for (const part of ugcSplit) {
167
+ if (/^[A-Z]/.test(part)) {
168
+ state = part.substring(0, 2);
169
+ if (part.includes('>')) {
170
+ const [start, end] = part.split('>');
171
+ const startNum = parseInt(start.substring(3), 10);
172
+ const endNum = parseInt(end, 10);
173
+ for (let j = startNum; j <= endNum; j++) {
174
+ zones.push(`${state}${format}${j.toString().padStart(3, '0')}`);
175
+ }
176
+ } else {
177
+ zones.push(part);
178
+ }
179
+ continue;
180
+ }
181
+ if (part.includes('>')) {
182
+ const [start, end] = part.split('>');
183
+ const startNum = parseInt(start, 10);
184
+ const endNum = parseInt(end, 10);
185
+ for (let j = startNum; j <= endNum; j++) {
186
+ zones.push(`${state}${format}${j.toString().padStart(3, '0')}`);
187
+ }
188
+ } else {
189
+ zones.push(`${state}${format}${part}`);
190
+ }
191
+ }
192
+ return zones.filter(item => item !== '');
193
+ }
194
+
195
+ }
196
+
197
+ export default UGCParser;