@cavoq/mcc-mnc-list 1.2.2 → 2.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/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 2.0.0
4
+
5
+ ### Migration
6
+
7
+ - Install `@cavoq/mcc-mnc-list` and update imports from `mcc-mnc-list` to `@cavoq/mcc-mnc-list`.
8
+ - Use Node `^22.22.2 || ^24.15.0 || >=26.0.0`, as required by jsdom 30. The previous package did not declare its transitive runtime requirements.
9
+ - TypeScript callers must handle nullable operator metadata and `undefined` from `find()`. Numeric filters and null filter objects now match the documented runtime API.
10
+ - Zero and empty-string filters are applied instead of being ignored. Arrays are rejected as filter objects, and combining MCC+MNC with a separate MCC or MNC always fails, including zero values.
11
+
12
+ ### Maintenance
13
+
14
+ - Refresh all seven Wikipedia source pages on 2026-09-12: 3,467 records (previously 3,446) and 14 status codes.
15
+ - Parse nested Wikipedia sections and both wrapped and direct headings. Preserve the country heading for international territories, rowspans, and rows without a final notes cell.
16
+ - Remove citations without truncating the preceding text; preserve meaningful bracketed text and separate line breaks.
17
+ - Propagate download, parse, and write errors with a failing exit code. Validate all sources and reject suspicious shrinkage before replacing data.
18
+ - Update jsdom 28.1.0 → 30.0.1, AVA 6.4.1 → 8.0.1, ESLint 10.0.1 → 10.10.0, and globals 17.3.0 → 17.12.0. `@eslint/js` 10.0.1 is already current. Pin pnpm 10.34.5 and refresh the lockfile.
19
+ - Add CI across Node 22, 24, and 26, plus npm publishing on matching version tags with prerelease handling and OIDC/token authentication.
20
+ - Limit package contents to the library, types, updater, data, and standard npm documentation. Use locked production dependencies and a writable work directory in Docker.
package/fetch.js CHANGED
@@ -1,9 +1,8 @@
1
1
  "use strict";
2
2
 
3
- const fs = require("fs");
4
- const path = require("path");
5
- const jsdom = require("jsdom");
6
- const { JSDOM } = jsdom;
3
+ const fs = require("node:fs/promises");
4
+ const path = require("node:path");
5
+ const { JSDOM } = require("jsdom");
7
6
 
8
7
  const WIKI_URL = "https://en.wikipedia.org/wiki/Mobile_country_code";
9
8
  const WIKI_URL_REGIONS = [
@@ -18,148 +17,198 @@ const WIKI_URL_REGIONS = [
18
17
  const MCC_MNC_OUTPUT_FILE = path.join(__dirname, "mcc-mnc-list.json");
19
18
  const STATUS_CODES_OUTPUT_FILE = path.join(__dirname, "status-codes.json");
20
19
 
21
- function fetch() {
22
- let records = [];
23
- let statusCodes = [];
20
+ async function fetch() {
21
+ const records = [];
22
+ const statusCodes = [];
24
23
 
25
- const process = (region, records, statusCodes) =>
26
- new Promise((resolve) => collect(resolve, region, records, statusCodes));
27
-
28
- (async function loop() {
29
- for (let i = 0; i < WIKI_URL_REGIONS.length; i++) {
30
- const region = WIKI_URL_REGIONS[i];
31
- await process(region, records, statusCodes);
32
- console.log(region, records.length, statusCodes.length);
33
- }
24
+ for (const region of WIKI_URL_REGIONS) {
25
+ await collect(region, records, statusCodes);
26
+ console.log(region, records.length, statusCodes.length);
27
+ }
34
28
 
35
- await process(WIKI_URL, records, statusCodes, true);
36
- console.log(WIKI_URL, records.length, statusCodes.length);
29
+ await collect(WIKI_URL, records, statusCodes, true);
30
+ console.log(WIKI_URL, records.length, statusCodes.length);
37
31
 
38
- writeData(records, statusCodes);
39
- })();
32
+ await writeData(records, statusCodes);
40
33
  }
41
34
 
42
- function collect(resolve, from, records, statusCodes, globals) {
43
- JSDOM.fromURL(from).then((dom) => {
44
- const { window } = dom;
45
- var content = window.document.querySelector(
46
- "#mw-content-text > .mw-parser-output"
35
+ async function collect(from, records, statusCodes, globals = false) {
36
+ const response = await globalThis.fetch(from, {
37
+ headers: {
38
+ "User-Agent": "mcc-mnc-list/2.0 (https://github.com/cavoq/mcc-mnc-list)",
39
+ },
40
+ signal: AbortSignal.timeout(60_000),
41
+ });
42
+
43
+ if (!response.ok) {
44
+ throw new Error(`HTTP ${response.status} fetching ${from}`);
45
+ }
46
+
47
+ const dom = new JSDOM(await response.text());
48
+
49
+ try {
50
+ const content = dom.window.document.querySelector(
51
+ "#mw-content-text > .mw-parser-output, body > .mw-parser-output"
47
52
  );
48
53
 
49
- if (!content.hasChildNodes()) {
50
- console.log("ERROR - empty content");
51
- return;
54
+ if (!content) {
55
+ throw new Error(`Wikipedia article content was not found: ${from}`);
52
56
  }
53
57
 
54
- content = removeCiteReferences(content);
58
+ removeCiteReferences(content);
55
59
 
56
- const children = content.childNodes;
57
- let recordType,
58
- countryInfo = { name: null, code: null };
60
+ // Headings and tables can be nested inside Wikipedia section elements.
61
+ const children = content.querySelectorAll("h2, h3, h4, table");
62
+ const previousCount = records.length;
63
+ let recordType = null;
64
+ let countryInfo = { name: null, code: null };
59
65
 
60
- for (let i = 0; i < children.length; i++) {
61
- let node = children[i];
66
+ for (const node of children) {
67
+ // Ignore hidden headings used as link targets inside table cells.
68
+ if (node.parentElement.closest("table")) {
69
+ continue;
70
+ }
62
71
 
63
- if (!node.textContent.trim()) {
72
+ if (node.nodeName === "H2") {
73
+ recordType = getRecordType(node);
74
+ countryInfo = { name: null, code: null };
64
75
  continue;
65
76
  }
66
77
 
67
- if (node.nodeName === "DIV") {
68
- switch (node.classList.value) {
69
- case "mw-heading mw-heading2":
70
- recordType = getRecordType(node);
71
- break;
72
- case "mw-heading mw-heading4":
73
- countryInfo = getCountryInfo(node);
74
- break;
75
- }
78
+ if (node.nodeName === "H3" || node.nodeName === "H4") {
79
+ countryInfo = getCountryInfo(node);
80
+ continue;
81
+ }
82
+
83
+ if (!recordType || (globals && recordType === "National")) {
84
+ continue;
85
+ }
86
+
87
+ const rows = getTableRows(node);
88
+ const headings = rows[0];
89
+
90
+ if (
91
+ !headings ||
92
+ headings.length < 2 ||
93
+ getCellText(headings[0]) !== "MCC" ||
94
+ getCellText(headings[1]) !== "MNC"
95
+ ) {
96
+ continue;
97
+ }
98
+
99
+ if (recordType === "National" && !countryInfo.code) {
100
+ throw new Error(`Operator table has no country heading: ${from}`);
76
101
  }
77
102
 
78
- if (node.nodeName === "TABLE") {
79
- if (globals && recordType === "National") {
103
+ for (const cols of rows.slice(1)) {
104
+ if (cols.every((cell) => cell.nodeName === "TH")) {
80
105
  continue;
81
106
  }
82
107
 
83
- let rows = node.querySelectorAll("tr");
84
-
85
- for (let j = 1; j < rows.length; j++) {
86
- let cols = rows[j].querySelectorAll("td");
87
-
88
- if (cols.length < 7) {
89
- // console.log('WARN invalid table row:', rows[j], node.textContent);
90
- continue;
91
- }
92
-
93
- /*
94
- Remove hidden child node of first cell:
95
-
96
- <td><div style="overflow:hidden;width:0;height:0;margin:-1ex;float:right">
97
- <h3><span class="mw-headline" id="United_States_of_America_-_US_-_313">United States of America - US - 313</span></h3>
98
- </div>313
99
- </td>
100
- */
101
- const mccChild = cols[0].querySelector("div");
102
- if (mccChild !== null) {
103
- cols[0].removeChild(mccChild);
104
- }
105
-
106
- let status = cleanup(cols[4].textContent);
107
- if (status === "Not Operational" || status === "Not opearational") {
108
- status = "Not operational";
109
- }
110
- if (status === "operational") {
111
- status = "Operational";
112
- }
113
-
114
- if (status && statusCodes.indexOf(status) === -1) {
115
- statusCodes.push(status);
116
- }
117
-
118
- records.push({
119
- type: recordType,
120
- countryName: countryInfo.name,
121
- countryCode: countryInfo.code,
122
- mcc: cleanup(cols[0].textContent),
123
- mnc: cleanup(cols[1].textContent),
124
- brand: cleanup(cols[2].textContent),
125
- operator: cleanup(cols[3].textContent),
126
- status: status,
127
- bands: cleanup(cols[5].textContent),
128
- notes: cleanup(cols[6].textContent),
129
- });
108
+ // Wikipedia occasionally omits the final, empty notes cell.
109
+ if (cols.length < 6 || cols.length > 7) {
110
+ throw new Error(`Unexpected operator row in ${from}`);
130
111
  }
112
+
113
+ const mcc = getCellText(cols[0], true);
114
+ const mnc = getCellText(cols[1]);
115
+
116
+ if (!/^\d{3}$/.test(mcc) || !mnc) {
117
+ throw new Error(`Invalid MCC/MNC row: ${mcc}/${mnc}`);
118
+ }
119
+
120
+ let status = getCellText(cols[4]);
121
+
122
+ if (/^not (operational|opearational)$/i.test(status)) {
123
+ status = "Not operational";
124
+ }
125
+ if (/^operational$/i.test(status)) {
126
+ status = "Operational";
127
+ }
128
+
129
+ if (status && statusCodes.indexOf(status) === -1) {
130
+ statusCodes.push(status);
131
+ }
132
+
133
+ records.push({
134
+ type: recordType,
135
+ countryName: countryInfo.name,
136
+ countryCode: countryInfo.code,
137
+ mcc: mcc,
138
+ mnc: mnc,
139
+ brand: getCellText(cols[2]),
140
+ operator: getCellText(cols[3]),
141
+ status: status,
142
+ bands: getCellText(cols[5]),
143
+ notes: cols[6] ? getCellText(cols[6]) : null,
144
+ });
131
145
  }
132
146
  }
133
147
 
134
- resolve();
135
- });
136
- }
148
+ const pageRecords = records.slice(previousCount);
137
149
 
138
- function writeData(records, statusCodes) {
139
- fs.writeFile(
140
- MCC_MNC_OUTPUT_FILE,
141
- JSON.stringify(records, null, 2),
142
- (err) => {
143
- if (err) {
144
- throw err;
150
+ if (!pageRecords.length) {
151
+ throw new Error(`No operator records found: ${from}`);
152
+ }
153
+
154
+ if (globals) {
155
+ for (const type of ["Test", "International"]) {
156
+ if (!pageRecords.some((record) => record.type === type)) {
157
+ throw new Error(`Missing ${type} operator table: ${from}`);
158
+ }
145
159
  }
146
- console.log("MCC-MNC list saved to " + MCC_MNC_OUTPUT_FILE);
147
- console.log("Total " + records.length + " records");
148
160
  }
149
- );
161
+ } finally {
162
+ dom.window.close();
163
+ }
164
+ }
165
+
166
+ async function writeData(records, statusCodes) {
167
+ let previous;
168
+
169
+ try {
170
+ previous = JSON.parse(await fs.readFile(MCC_MNC_OUTPUT_FILE, "utf8"));
171
+ } catch (error) {
172
+ if (error.code !== "ENOENT") {
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ if (!records.length) {
178
+ throw new Error("Refusing to save an empty list");
179
+ }
180
+
181
+ if (previous && records.length < previous.length * 0.8) {
182
+ throw new Error(
183
+ `Refusing suspicious list shrinkage: ${previous.length} to ${records.length} records`
184
+ );
185
+ }
150
186
 
151
187
  statusCodes.sort();
152
188
 
153
- fs.writeFile(
154
- STATUS_CODES_OUTPUT_FILE,
155
- JSON.stringify(statusCodes, null, 2),
156
- (err) => {
157
- if (err) {
158
- throw err;
159
- }
160
- console.log("Status codes saved to " + STATUS_CODES_OUTPUT_FILE);
189
+ const outputs = [
190
+ [MCC_MNC_OUTPUT_FILE, records],
191
+ [STATUS_CODES_OUTPUT_FILE, statusCodes],
192
+ ];
193
+
194
+ // Finish every download and validation before replacing either data file.
195
+ try {
196
+ for (const [file, data] of outputs) {
197
+ await fs.writeFile(`${file}.tmp`, JSON.stringify(data, null, 2) + "\n");
161
198
  }
162
- );
199
+
200
+ for (const [file] of outputs) {
201
+ await fs.rename(`${file}.tmp`, file);
202
+ }
203
+ } finally {
204
+ for (const [file] of outputs) {
205
+ await fs.rm(`${file}.tmp`, { force: true });
206
+ }
207
+ }
208
+
209
+ console.log("MCC-MNC list saved to " + MCC_MNC_OUTPUT_FILE);
210
+ console.log("Total " + records.length + " records");
211
+ console.log("Status codes saved to " + STATUS_CODES_OUTPUT_FILE);
163
212
  }
164
213
 
165
214
  function getRecordType(node) {
@@ -169,81 +218,101 @@ function getRecordType(node) {
169
218
  "International operators": "International",
170
219
  };
171
220
 
172
- const h2Node = Array.from(node.childNodes).find(
173
- (child) => child.nodeName === "H2"
174
- );
221
+ return recordTypeMap[cleanup(node.textContent)] || null;
222
+ }
175
223
 
176
- if (h2Node) {
177
- const sectionName = h2Node.textContent.trim();
224
+ function getCountryInfo(node) {
225
+ const countryText = cleanup(node.textContent);
226
+ const match =
227
+ countryText &&
228
+ countryText.match(/^(.*?)\s+[–—-]\s+([A-Z]{2}(?:[-/][A-Z]{2})*)$/);
229
+
230
+ return {
231
+ name: match ? match[1] : null,
232
+ code: match ? match[2] : null,
233
+ };
234
+ }
178
235
 
179
- if (sectionName.length > 1) {
180
- if (
181
- sectionName === "See also" ||
182
- sectionName === "External links" ||
183
- sectionName === "National MNC Authorities"
184
- ) {
185
- return null;
186
- }
236
+ function getTableRows(table) {
237
+ const rows = [];
238
+ const spans = [];
187
239
 
188
- return recordTypeMap[sectionName] || "other";
189
- }
190
- }
240
+ for (const row of table.rows) {
241
+ const cells = Array.from(row.cells);
242
+ const cols = [];
243
+ let index = 0;
191
244
 
192
- return "other";
193
- }
245
+ // Carry merged cells forward so column positions remain consistent.
246
+ while (cells.length || spans[index]) {
247
+ const span = spans[index];
194
248
 
195
- function getCountryInfo(node) {
196
- const h4Node = Array.from(node.childNodes).find(
197
- (child) => child.nodeName === "H4"
198
- );
249
+ if (span) {
250
+ cols.push(span.cell);
251
+ span.remaining--;
252
+
253
+ if (span.remaining === 0) {
254
+ delete spans[index];
255
+ }
199
256
 
200
- let countryName = null;
201
- let countryCode = null;
257
+ index++;
258
+ continue;
259
+ }
202
260
 
203
- if (h4Node) {
204
- const countryText = h4Node.textContent.trim();
205
- const dashPos = countryText.indexOf("–");
261
+ const cell = cells.shift();
206
262
 
207
- if (dashPos !== -1) {
208
- countryName = countryText.substring(0, dashPos).trim();
209
- countryCode = countryText.substring(dashPos + 1).trim();
263
+ for (let column = 0; column < cell.colSpan; column++) {
264
+ cols.push(cell);
265
+
266
+ if (cell.rowSpan > 1) {
267
+ spans[index] = { cell: cell, remaining: cell.rowSpan - 1 };
268
+ }
269
+
270
+ index++;
271
+ }
210
272
  }
273
+
274
+ rows.push(cols);
211
275
  }
212
276
 
213
- return { name: countryName, code: countryCode };
277
+ return rows;
214
278
  }
215
279
 
216
- function removeCiteReferences(nodes) {
217
- let links = nodes.querySelectorAll("a");
218
- for (let i = 0; i < links.length; i++) {
219
- let link = links[i];
220
- let href = link.getAttribute("href");
221
- if (href.substr(0, 10) === "#cite_note") {
222
- link.remove();
280
+ function getCellText(cell, isMcc = false) {
281
+ const copy = cell.cloneNode(true);
282
+
283
+ if (isMcc) {
284
+ // Some MCC cells contain a hidden heading used as a link target.
285
+ for (const node of copy.querySelectorAll("div")) {
286
+ node.remove();
223
287
  }
224
288
  }
225
289
 
226
- return nodes;
290
+ for (const node of copy.querySelectorAll("br")) {
291
+ node.replaceWith(" / ");
292
+ }
293
+
294
+ return cleanup(copy.textContent);
227
295
  }
228
296
 
229
- function cleanup(str) {
230
- str = str.trim();
231
- str = removeCitationNeeded(str);
232
- if (str.substr(0, 1) === "[" && str.substr(-1) === "]") {
233
- // remove brackets-only like [7]
234
- str = "";
235
- }
236
- if (str.substr(0, 1) != "[" && str.substr(-1) === "]") {
237
- // remove postfix references like ...[7]
238
- let index = str.lastIndexOf("[");
239
- str = str.substr(0, index - 1).trim();
297
+ function removeCiteReferences(content) {
298
+ const references = content.querySelectorAll(
299
+ "sup.reference, sup.mw-ref, .mw-editsection, a[href*='#cite_note']"
300
+ );
301
+
302
+ for (const reference of references) {
303
+ reference.remove();
240
304
  }
241
- str = str.replace(/“/g, '"');
242
- return str.length ? str : null;
243
305
  }
244
306
 
245
- function removeCitationNeeded(str) {
246
- return str.replace(/\[citation needed\]/g, "");
307
+ function cleanup(str) {
308
+ str = str.replace(/\[(?:\d+(?:[ ,–-]\d+)*|citation needed)\]/gi, "");
309
+ str = str.replace(/[“”]/g, '"');
310
+ str = str.replace(/\s+/g, " ").trim();
311
+
312
+ return str.length ? str : null;
247
313
  }
248
314
 
249
- fetch();
315
+ fetch().catch((error) => {
316
+ console.error(error.message);
317
+ process.exitCode = 1;
318
+ });
package/index.js CHANGED
@@ -16,13 +16,13 @@ function filter(filters) {
16
16
  return records;
17
17
  }
18
18
 
19
- if (typeof filters !== "object") {
19
+ if (typeof filters !== "object" || Array.isArray(filters)) {
20
20
  throw new TypeError("Invalid parameter (object expected)");
21
21
  }
22
22
 
23
23
  let statusCode, mcc, mnc, countryCode;
24
24
 
25
- if (filters.statusCode) {
25
+ if (filters.statusCode !== undefined) {
26
26
  statusCode = filters.statusCode;
27
27
  if (statusCodeList.indexOf(statusCode) === -1) {
28
28
  throw new TypeError(
@@ -31,7 +31,7 @@ function filter(filters) {
31
31
  }
32
32
  }
33
33
 
34
- if (filters.mccmnc) {
34
+ if (filters.mccmnc !== undefined) {
35
35
  let mccmnc;
36
36
  if (
37
37
  typeof filters.mccmnc === "string" ||
@@ -41,18 +41,18 @@ function filter(filters) {
41
41
  } else {
42
42
  throw new TypeError("Invalid mccmnc parameter (string expected)");
43
43
  }
44
- mcc = mccmnc.substr(0, 3);
45
- mnc = mccmnc.substr(3);
44
+ mcc = mccmnc.slice(0, 3);
45
+ mnc = mccmnc.slice(3);
46
46
  }
47
47
 
48
- if (filters.mcc && mcc) {
48
+ if (filters.mcc !== undefined && filters.mccmnc !== undefined) {
49
49
  throw new TypeError("Don't use mccmnc and mcc parameter at once");
50
50
  }
51
- if (filters.mnc && mnc) {
51
+ if (filters.mnc !== undefined && filters.mccmnc !== undefined) {
52
52
  throw new TypeError("Don't use mccmnc and mnc parameter at once");
53
53
  }
54
54
 
55
- if (filters.mcc) {
55
+ if (filters.mcc !== undefined) {
56
56
  if (typeof filters.mcc === "string" || typeof filters.mcc === "number") {
57
57
  mcc = String(filters.mcc);
58
58
  } else {
@@ -60,7 +60,7 @@ function filter(filters) {
60
60
  }
61
61
  }
62
62
 
63
- if (filters.mnc) {
63
+ if (filters.mnc !== undefined) {
64
64
  if (typeof filters.mnc === "string" || typeof filters.mnc === "number") {
65
65
  mnc = String(filters.mnc);
66
66
  } else {
@@ -78,16 +78,16 @@ function filter(filters) {
78
78
 
79
79
  let result = records;
80
80
 
81
- if (statusCode) {
81
+ if (statusCode !== undefined) {
82
82
  result = result.filter((record) => record["status"] === statusCode);
83
83
  }
84
- if (countryCode) {
84
+ if (countryCode !== undefined) {
85
85
  result = result.filter((record) => record["countryCode"] === countryCode);
86
86
  }
87
- if (mcc) {
87
+ if (mcc !== undefined) {
88
88
  result = result.filter((record) => record["mcc"] === mcc);
89
89
  }
90
- if (mnc) {
90
+ if (mnc !== undefined) {
91
91
  result = result.filter((record) => record["mnc"] === mnc);
92
92
  }
93
93
 
@@ -95,7 +95,7 @@ function filter(filters) {
95
95
  }
96
96
 
97
97
  function find(filters) {
98
- // return the first element of undefined, as filter will always return an array
98
+ // return the first element or undefined, as filter will always return an array
99
99
  return filter(filters)[0];
100
100
  }
101
101