@nekosuneprojects/warframe-api 0.0.0-stage → 1.0.1

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -2
  3. package/data/characters/amesha.json +226 -0
  4. package/data/characters/ash.json +1354 -0
  5. package/data/characters/ashprime.json +1994 -0
  6. package/data/characters/atlas.json +916 -0
  7. package/data/characters/atlasprime.json +1632 -0
  8. package/data/characters/banshee.json +1036 -0
  9. package/data/characters/bansheeprime.json +1750 -0
  10. package/data/characters/baruuk.json +555 -0
  11. package/data/characters/baruukprime.json +1298 -0
  12. package/data/characters/bonewidow.json +354 -0
  13. package/data/characters/caliban.json +434 -0
  14. package/data/characters/calibanprime.json +683 -0
  15. package/data/characters/chroma.json +1344 -0
  16. package/data/characters/chromaprime.json +1977 -0
  17. package/data/characters/citrine.json +220 -0
  18. package/data/characters/citrineprime.json +62 -0
  19. package/data/characters/cyte09.json +218 -0
  20. package/data/characters/dagath.json +194 -0
  21. package/data/characters/dante.json +223 -0
  22. package/data/characters/elytron.json +300 -0
  23. package/data/characters/ember.json +1209 -0
  24. package/data/characters/emberprime.json +1678 -0
  25. package/data/characters/equinox.json +958 -0
  26. package/data/characters/equinoxprime.json +1644 -0
  27. package/data/characters/excalibur.json +1603 -0
  28. package/data/characters/excaliburprime.json +1524 -0
  29. package/data/characters/excaliburumbra.json +397 -0
  30. package/data/characters/follie.json +246 -0
  31. package/data/characters/frost.json +1330 -0
  32. package/data/characters/frostprime.json +1686 -0
  33. package/data/characters/gara.json +925 -0
  34. package/data/characters/garaprime.json +1494 -0
  35. package/data/characters/garuda.json +851 -0
  36. package/data/characters/garudaprime.json +1449 -0
  37. package/data/characters/gauss.json +506 -0
  38. package/data/characters/gaussprime.json +1077 -0
  39. package/data/characters/grendel.json +463 -0
  40. package/data/characters/grendelprime.json +772 -0
  41. package/data/characters/gyre.json +381 -0
  42. package/data/characters/gyreprime.json +403 -0
  43. package/data/characters/harrow.json +916 -0
  44. package/data/characters/harrowprime.json +1700 -0
  45. package/data/characters/helminth.json +220 -0
  46. package/data/characters/hildryn.json +627 -0
  47. package/data/characters/hildrynprime.json +996 -0
  48. package/data/characters/hydroid.json +1028 -0
  49. package/data/characters/hydroidprime.json +1621 -0
  50. package/data/characters/inaros.json +976 -0
  51. package/data/characters/inarosprime.json +1721 -0
  52. package/data/characters/itzal.json +465 -0
  53. package/data/characters/ivara.json +1349 -0
  54. package/data/characters/ivaraprime.json +1921 -0
  55. package/data/characters/jade.json +240 -0
  56. package/data/characters/khora.json +804 -0
  57. package/data/characters/khoraprime.json +1403 -0
  58. package/data/characters/koumei.json +221 -0
  59. package/data/characters/kullervo.json +191 -0
  60. package/data/characters/lavos.json +511 -0
  61. package/data/characters/lavosprime.json +711 -0
  62. package/data/characters/limbo.json +1363 -0
  63. package/data/characters/limboprime.json +2084 -0
  64. package/data/characters/loki.json +1209 -0
  65. package/data/characters/lokiprime.json +1457 -0
  66. package/data/characters/mag.json +2089 -0
  67. package/data/characters/magprime.json +2532 -0
  68. package/data/characters/mesa.json +1195 -0
  69. package/data/characters/mesaprime.json +2059 -0
  70. package/data/characters/mirage.json +1561 -0
  71. package/data/characters/mirageprime.json +2257 -0
  72. package/data/characters/narin.json +43 -0
  73. package/data/characters/nekros.json +1235 -0
  74. package/data/characters/nekrosprime.json +1882 -0
  75. package/data/characters/nezha.json +868 -0
  76. package/data/characters/nezhaprime.json +1695 -0
  77. package/data/characters/nidus.json +1027 -0
  78. package/data/characters/nidusprime.json +1706 -0
  79. package/data/characters/nokko.json +308 -0
  80. package/data/characters/nova.json +1226 -0
  81. package/data/characters/novaprime.json +1667 -0
  82. package/data/characters/nyx.json +1610 -0
  83. package/data/characters/nyxprime.json +1967 -0
  84. package/data/characters/oberon.json +1249 -0
  85. package/data/characters/oberonprime.json +1872 -0
  86. package/data/characters/octavia.json +1049 -0
  87. package/data/characters/octaviaprime.json +1735 -0
  88. package/data/characters/odonata.json +336 -0
  89. package/data/characters/odonataprime.json +787 -0
  90. package/data/characters/oraxia.json +246 -0
  91. package/data/characters/orionsirius.json +134 -0
  92. package/data/characters/protea.json +521 -0
  93. package/data/characters/proteaprime.json +992 -0
  94. package/data/characters/qorvex.json +214 -0
  95. package/data/characters/revenant.json +931 -0
  96. package/data/characters/revenantprime.json +1494 -0
  97. package/data/characters/rhino.json +1344 -0
  98. package/data/characters/rhinoprime.json +1675 -0
  99. package/data/characters/saryn.json +1210 -0
  100. package/data/characters/sarynprime.json +2042 -0
  101. package/data/characters/sevagoth.json +478 -0
  102. package/data/characters/sevagothprime.json +910 -0
  103. package/data/characters/siriusorion.json +247 -0
  104. package/data/characters/styanax.json +320 -0
  105. package/data/characters/styanaxprime.json +321 -0
  106. package/data/characters/temple.json +271 -0
  107. package/data/characters/titania.json +1227 -0
  108. package/data/characters/titaniaprime.json +2000 -0
  109. package/data/characters/trinity.json +1097 -0
  110. package/data/characters/trinityprime.json +1849 -0
  111. package/data/characters/uriel.json +215 -0
  112. package/data/characters/valkyr.json +1165 -0
  113. package/data/characters/valkyrprime.json +1969 -0
  114. package/data/characters/vauban.json +1067 -0
  115. package/data/characters/vaubanprime.json +1614 -0
  116. package/data/characters/voidrig.json +355 -0
  117. package/data/characters/volt.json +1324 -0
  118. package/data/characters/voltprime.json +1843 -0
  119. package/data/characters/voruna.json +293 -0
  120. package/data/characters/vorunaprime.json +431 -0
  121. package/data/characters/wisp.json +824 -0
  122. package/data/characters/wispprime.json +908 -0
  123. package/data/characters/wukong.json +927 -0
  124. package/data/characters/wukongprime.json +1754 -0
  125. package/data/characters/xaku.json +620 -0
  126. package/data/characters/xakuprime.json +879 -0
  127. package/data/characters/yareli.json +409 -0
  128. package/data/characters/yareliprime.json +683 -0
  129. package/data/characters/zephyr.json +1149 -0
  130. package/data/characters/zephyrprime.json +1864 -0
  131. package/data/codes/warframe.json +20824 -0
  132. package/data/meta.json +14 -0
  133. package/index.js +11 -0
  134. package/package.json +48 -4
  135. package/src/catalog.js +105 -0
  136. package/src/client-error.js +9 -0
  137. package/src/data-source.js +7 -0
  138. package/src/data-store.js +138 -0
  139. package/src/provider.js +22 -0
  140. package/src/tracker.js +115 -0
  141. package/src/warframe-client.js +57 -0
  142. package/src/warframe-codes.js +82 -0
  143. package/src/wfcd.js +93 -0
package/data/meta.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "Warframe",
3
+ "title": "Cooperative science-fiction action game by Digital Extremes.",
4
+ "engine": "Evolution",
5
+ "Platform": "Windows, PlayStation, Xbox, Nintendo Switch, iOS, Android",
6
+ "Release date": "March 25, 2013",
7
+ "Genre": "Action RPG, Third-person shooter, Online co-op",
8
+ "developers": "Digital Extremes",
9
+ "publisher": "Digital Extremes",
10
+ "aliases": "WF, Warframe",
11
+ "image": "https://www.warframe.com/favicon.ico",
12
+ "description": "Online action game built around collectible Warframes, weapons, companions and the Origin System.",
13
+ "url": "https://www.warframe.com/"
14
+ }
package/index.js ADDED
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+ const {createTracker,TrackerError}=require('./src/tracker');
3
+ const provider=require('./src/provider');
4
+
5
+ module.exports=createTracker;
6
+ module.exports.Warframe=createTracker;
7
+ module.exports.createTracker=createTracker;
8
+ module.exports.TrackerError=TrackerError;
9
+ module.exports.platforms=provider.platforms;
10
+ module.exports.worldstateFields=provider.worldstateFields;
11
+ module.exports.staticKeys=provider.staticKeys;
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@nekosuneprojects/warframe-api",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "WarframeStat.us live API, offline Warframe characters, and redeem-code/glyph JSON browsing with independent GitHub synchronization.",
5
+ "main": "index.js",
6
+ "type": "commonjs",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/NekoSuneProjectsNPM/game-tracker.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/NekoSuneProjectsNPM/game-tracker/issues"
13
+ },
14
+ "funding": "https://github.com/NekoSuneProjectsNPM/game-tracker?sponsor",
15
+ "keywords": [
16
+ "warframe",
17
+ "warframe-api",
18
+ "warframestat",
19
+ "wfcd",
20
+ "worldstate",
21
+ "characters",
22
+ "json",
23
+ "offline",
24
+ "game-tracker",
25
+ "redeem-codes",
26
+ "glyphs",
27
+ "creator-glyphs"
28
+ ],
29
+ "author": "NekoSuneVR",
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "files": [
35
+ "index.js",
36
+ "src/",
37
+ "data/",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "test": "node --test",
43
+ "check": "node scripts/validate-package.js",
44
+ "prepack": "npm run check && npm test"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "registry": "https://registry.npmjs.org/"
49
+ }
50
+ }
package/src/catalog.js ADDED
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ // Common reusable JSON discovery across any game or category.
4
+ const path = require('node:path');
5
+
6
+ const normalise = value => String(value ?? '').trim().normalize('NFKC').toLowerCase().replace(/[\s_-]+/g, ' ');
7
+ const slug = value => normalise(value).replace(/[^a-z0-9 ]/g, '').replace(/ +/g, '');
8
+
9
+ function descriptor(file, record) {
10
+ const type = file.split('/')[0] || 'data';
11
+ const id = path.posix.basename(file, '.json');
12
+ const name = typeof record?.name === 'string' ? record.name :
13
+ typeof record?.title === 'string' ? record.title : id.replace(/[-_]/g, ' ');
14
+ return { id: String(record?.id || id), name, type, path: file };
15
+ }
16
+ function valuesFrom(record, limit = 10000) {
17
+ const values = [];
18
+ const stack = [record];
19
+ while (stack.length && values.length < limit) {
20
+ const value = stack.pop();
21
+ if (typeof value === 'string' || typeof value === 'number') values.push(String(value));
22
+ else if (Array.isArray(value)) stack.push(...value.slice(0,1000));
23
+ else if (value && typeof value === 'object') stack.push(...Object.values(value).slice(0,1000));
24
+ }
25
+ return values;
26
+ }
27
+ function createCatalog(data) {
28
+ if (!data || typeof data.list !== 'function' || typeof data.get !== 'function')
29
+ throw new TypeError('A JSON data store is required');
30
+
31
+ async function all(category) {
32
+ const files = await data.list();
33
+ const prefix = category ? category + '/' : '';
34
+ const matches = [];
35
+ for (const file of files) {
36
+ if (!file.endsWith('.json') || (category && !file.startsWith(prefix))) continue;
37
+ try {
38
+ const record = await data.get(file);
39
+ matches.push({ ...descriptor(file, record), record });
40
+ } catch (error) {
41
+ if (error.code === 'ENOENT') continue;
42
+ throw error;
43
+ }
44
+ }
45
+ return matches;
46
+ }
47
+
48
+ async function search(query, { category, limit = 50 } = {}) {
49
+ if (typeof query !== 'string' || !query.trim()) throw new TypeError('Search query is required');
50
+ if (category && !/^[a-z0-9_-]+$/.test(category)) throw new TypeError('Invalid category');
51
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new RangeError('limit must be between 1 and 500');
52
+ const needle = normalise(query);
53
+ const results = [];
54
+ for (const item of await all(category)) {
55
+ const shortFields = [item.id, item.name, item.path, item.record?.title, ...(Array.isArray(item.record?.aliases) ? item.record.aliases : [])];
56
+ const direct = shortFields.some(v => v != null && normalise(v).includes(needle));
57
+ const content = !direct && valuesFrom(item.record).some(v => normalise(v).includes(needle));
58
+ if (!direct && !content) continue;
59
+ const score = normalise(item.name) === needle || normalise(item.id) === needle || slug(item.name) === slug(query) ? 0 :
60
+ direct ? 1 : 2;
61
+ results.push({ id:item.id, name:item.name, type:item.type, path:item.path, score });
62
+ }
63
+ results.sort((a,b) => a.score-b.score || a.name.localeCompare(b.name) || a.path.localeCompare(b.path));
64
+ const found = results.slice(0,limit).map(({score,...result})=>result);
65
+ return {
66
+ query,
67
+ total: results.length,
68
+ results: found,
69
+ message: results.length ? `Found ${results.length} result(s) for "${query}".` : `We don't have anything related to "${query}".`
70
+ };
71
+ }
72
+
73
+ async function get(category, idOrName) {
74
+ if (typeof category !== 'string' || !/^[a-z0-9_-]+$/.test(category))
75
+ throw new TypeError('Invalid category');
76
+ if (typeof idOrName !== 'string' || !idOrName.trim()) throw new TypeError('Record name or ID is required');
77
+ const needle = normalise(idOrName);
78
+ const matches = (await all(category)).filter(item =>
79
+ normalise(item.id) === needle || normalise(item.name) === needle ||
80
+ slug(item.id) === slug(idOrName) || slug(item.name) === slug(idOrName) ||
81
+ normalise(item.path) === needle);
82
+ if (!matches.length) {
83
+ const error = new Error(`We don't have anything related to "${idOrName}" in ${category}.`);
84
+ error.code = 'NOT_FOUND';
85
+ throw error;
86
+ }
87
+ if (matches.length > 1) {
88
+ const error = new Error(`Multiple ${category} records match "${idOrName}". Select an exact result path.`);
89
+ error.code = 'AMBIGUOUS';
90
+ error.results = matches.map(({record,...info})=>info);
91
+ throw error;
92
+ }
93
+ return matches[0].record;
94
+ }
95
+
96
+ return Object.freeze({
97
+ search,
98
+ get,
99
+ character: id => get('characters', id),
100
+ item: id => get('items', id),
101
+ code: id => get('codes', id),
102
+ map: id => get('maps', id)
103
+ });
104
+ }
105
+ module.exports = { createCatalog };
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+ class TrackerError extends Error {
3
+ constructor(message,{status,cause}={}) {
4
+ super(message,{cause});
5
+ this.name='TrackerError';
6
+ if(status!==undefined)this.status=status;
7
+ }
8
+ }
9
+ module.exports={TrackerError};
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+ const path=require('node:path');
3
+ module.exports=Object.freeze({
4
+ gameId:'warframe',
5
+ bundledDir:path.join(__dirname,'..','data'),
6
+ github:{owner:'NekoSuneProjectsNPM',repo:'game-tracker',branch:'JsonData',directory:'games/warframe'}
7
+ });
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+ const fs = require('node:fs/promises');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+
6
+ function safePath(value) {
7
+ if (typeof value !== 'string' || !value || value.includes('\\') || value.includes('\0') ||
8
+ value.startsWith('/') || value.split('/').some(v => !v || v === '.' || v === '..') ||
9
+ !/^[a-zA-Z0-9_./-]+$/.test(value)) throw new TypeError('Unsafe JSON data path');
10
+ if (!value.endsWith('.json')) throw new TypeError('Only .json files are supported');
11
+ return value;
12
+ }
13
+ function sourceConfig(source) {
14
+ if (!source) return null;
15
+ const { owner, repo, branch = 'main', directory } = source;
16
+ for (const part of [owner, repo, branch]) {
17
+ if (typeof part !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(part)) throw new TypeError('Invalid GitHub repository source');
18
+ }
19
+ if (typeof directory !== 'string' || !directory || directory.split('/').some(x => !x || x === '.' || x === '..') ||
20
+ !/^[a-zA-Z0-9_/-]+$/.test(directory)) throw new TypeError('Invalid GitHub source directory');
21
+ return { owner, repo, branch, directory };
22
+ }
23
+ function createDataStore({ gameId, cacheDir, bundledDir, github, githubToken, syncFiles, fetchImpl = globalThis.fetch } = {}) {
24
+ if (typeof gameId !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/.test(gameId)) throw new TypeError('Valid gameId required');
25
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetch is required');
26
+ const source = sourceConfig(github);
27
+ if (syncFiles !== undefined && (!Array.isArray(syncFiles) || syncFiles.length === 0)) throw new TypeError('syncFiles must be a nonempty array of JSON paths');
28
+ const whitelist = syncFiles === undefined ? null : new Set(syncFiles.map(safePath));
29
+ if (githubToken !== undefined && (typeof githubToken !== 'string' || !githubToken.trim())) throw new TypeError('githubToken must be a nonempty string');
30
+ const githubHeaders = { Accept: 'application/vnd.github+json', 'User-Agent': 'NekoSune-Game-Tracker', ...(githubToken ? { Authorization: 'Bearer ' + githubToken } : {}) };
31
+ const cache = path.resolve(cacheDir || path.join(os.homedir(), '.cache', 'nekosune-game-tracker', gameId));
32
+ const bundled = bundledDir ? path.resolve(bundledDir) : null;
33
+ const manifestFile = path.join(cache, '.manifest.json');
34
+ async function loadManifest() {
35
+ try {
36
+ const current = JSON.parse(await fs.readFile(manifestFile, 'utf8'));
37
+ if (current.source !== JSON.stringify({source, files: syncFiles || null}) || !current.files || typeof current.files !== 'object') return {};
38
+ return current.files;
39
+ } catch (err) { if (err.code === 'ENOENT' || err instanceof SyntaxError) return {}; throw err; }
40
+ }
41
+ async function atomicWrite(file, content) {
42
+ await fs.mkdir(path.dirname(file), { recursive: true });
43
+ const tmp = file + '.' + process.pid + '.' + require('node:crypto').randomBytes(6).toString('hex') + '.tmp';
44
+ try { await fs.writeFile(tmp, content); await fs.rename(tmp, file); }
45
+ catch (err) { await fs.rm(tmp, { force: true }); throw err; }
46
+ }
47
+ async function get(file) {
48
+ const relative = safePath(file);
49
+ for (const base of [cache, bundled].filter(Boolean)) {
50
+ try { return JSON.parse(await fs.readFile(path.join(base, relative), 'utf8')); }
51
+ catch (err) { if (err.code !== 'ENOENT') throw err; }
52
+ }
53
+ throw Object.assign(new Error('Local JSON not found: ' + relative), { code: 'ENOENT' });
54
+ }
55
+ async function list() {
56
+ const results = new Set();
57
+ async function walk(dir, relative = '') {
58
+ let entries;
59
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); }
60
+ catch (err) { if (err.code === 'ENOENT') return; throw err; }
61
+ for (const entry of entries) {
62
+ if (entry.isSymbolicLink()) continue;
63
+ const rel = relative ? relative + '/' + entry.name : entry.name;
64
+ if (entry.isDirectory()) await walk(path.join(dir, entry.name), rel);
65
+ else if (entry.isFile() && rel.endsWith('.json') && rel !== '.manifest.json') {
66
+ try { safePath(rel); results.add(rel); } catch {}
67
+ }
68
+ }
69
+ }
70
+ if (bundled) await walk(bundled);
71
+ await walk(cache);
72
+ return [...results].sort();
73
+ }
74
+ async function sync({ signal } = {}) {
75
+ if (!source) throw new Error('No GitHub JSON source configured in this tracker branch');
76
+ const { owner, repo, branch, directory } = source;
77
+ const api = 'https://api.github.com/repos/' + owner + '/' + repo + '/git/trees/' + encodeURIComponent(branch) + '?recursive=1';
78
+ const request = await fetchImpl(api, { headers: githubHeaders, signal });
79
+ if (!request.ok) throw new Error('GitHub tree request failed: HTTP ' + request.status);
80
+ const tree = await request.json();
81
+ if (tree.truncated || !Array.isArray(tree.tree)) throw new Error('GitHub directory listing incomplete; refusing partial sync');
82
+ const prefix = directory + '/';
83
+ const files = tree.tree.filter(item => item.type === 'blob' && item.path.startsWith(prefix) && item.path.endsWith('.json') && (!whitelist || whitelist.has(item.path.slice(prefix.length))))
84
+ .map(item => ({ name: safePath(item.path.slice(prefix.length)), sha: item.sha, size: item.size }));
85
+ if (files.length > 5000) throw new Error('Too many JSON files');
86
+ const old = await loadManifest();
87
+ const next = { ...old };
88
+ let updated = 0, unchanged = 0;
89
+ for (const item of files) {
90
+ if (!/^[0-9a-f]{40}$/.test(item.sha) || item.size > 5 * 1024 * 1024) throw new Error('Invalid or oversized GitHub JSON blob');
91
+ const target = path.join(cache, item.name);
92
+ if (old[item.name] === item.sha) {
93
+ try {
94
+ const existing = await fs.readFile(target);
95
+ const existingHash = require('node:crypto').createHash('sha1')
96
+ .update('blob ' + existing.length + '\0').update(existing).digest('hex');
97
+ JSON.parse(existing.toString('utf8'));
98
+ if (existingHash === item.sha) { unchanged++; continue; }
99
+ } catch (err) {
100
+ if (err.code !== 'ENOENT' && !(err instanceof SyntaxError)) throw err;
101
+ }
102
+ }
103
+ // GitHub Git Blob API supports private repositories with a fine-grained read token.
104
+ // Fetch by content SHA so the downloaded file matches the exact tree snapshot.
105
+ const blobUrl = 'https://api.github.com/repos/' + owner + '/' + repo + '/git/blobs/' + item.sha;
106
+ const response = await fetchImpl(blobUrl, { headers: githubHeaders, signal });
107
+ if (!response.ok) throw new Error('GitHub JSON download failed (' + item.name + '): HTTP ' + response.status);
108
+ const blob = await response.json();
109
+ if (blob.encoding !== 'base64' || typeof blob.content !== 'string')
110
+ throw new Error('Unexpected GitHub blob encoding for ' + item.name);
111
+ const buffer = Buffer.from(blob.content.replace(/\s/g, ''), 'base64');
112
+ if (buffer.length > 5 * 1024 * 1024) throw new Error('JSON file too large');
113
+ const gitHash = require('node:crypto').createHash('sha1').update('blob ' + buffer.length + '\0').update(buffer).digest('hex');
114
+ if (gitHash !== item.sha) throw new Error('GitHub JSON checksum mismatch: ' + item.name);
115
+ JSON.parse(buffer.toString('utf8'));
116
+ await atomicWrite(target, buffer);
117
+ next[item.name] = item.sha;
118
+ updated++;
119
+ }
120
+ // Cache is additive: do not automatically delete removed upstream files or user files.
121
+ await atomicWrite(manifestFile, JSON.stringify({ source: JSON.stringify({source, files: syncFiles || null}), files: next }, null, 2) + '\n');
122
+ return { updated, unchanged, checked: files.length, cacheDir: cache };
123
+ }
124
+ // Ensures a newly published remote JSON record is available even when it
125
+ // was absent from the npm package and from this machine's cache.
126
+ async function ensure(file, { signal, refresh = false } = {}) {
127
+ const relative = safePath(file);
128
+ if (!refresh) {
129
+ try { return await get(relative); }
130
+ catch (err) { if (err.code !== 'ENOENT') throw err; }
131
+ }
132
+ if (!source) return get(relative);
133
+ await sync({ signal });
134
+ return get(relative);
135
+ }
136
+ return Object.freeze({ get, list, sync, ensure, cacheDir: cache });
137
+ }
138
+ module.exports = { createDataStore };
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+ const BASE = 'https://api.warframestat.us/';
3
+ const PLATFORMS = Object.freeze(['pc','ps4','psn','xb1','swi','ns']);
4
+ const WORLDSTATE_FIELDS = Object.freeze([
5
+ 'alerts','arbitration','archonHunt','cambionCycle','cetusCycle','conclaveChallenges',
6
+ 'constructionProgress','dailyDeals','darvo','earthCycle','events','fissures',
7
+ 'flashSales','globalUpgrades','invasions','kuva','news','nightwave','persistentEnemies',
8
+ 'sentientOutposts','simaris','sortie','steelPath','syndicateMissions','thermia',
9
+ 'vallisCycle','voidTrader','weeklyChallenges'
10
+ ]);
11
+ const STATIC_KEYS = Object.freeze([
12
+ 'arcanes','synthTargets','solNodes','conclave','events','operationTypes',
13
+ 'persistentEnemy','sortie','upgradeTypes','fissureModifiers','archonShards',
14
+ 'factions','missionTypes','languages','locales','mods','weapons','warframes','items'
15
+ ]);
16
+ module.exports = Object.freeze({
17
+ baseUrl: BASE,
18
+ auth: Object.freeze({type:'none'}),
19
+ platforms: PLATFORMS,
20
+ worldstateFields: WORLDSTATE_FIELDS,
21
+ staticKeys: STATIC_KEYS
22
+ });
package/src/tracker.js ADDED
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+ const provider=require('./provider');
3
+ const {createWarframeClient,parameter,optionsQuery}=require('./warframe-client');
4
+ const {createDataStore}=require('./data-store');
5
+ const {createCatalog}=require('./catalog');
6
+ const {createWarframeCodes}=require('./warframe-codes');
7
+ const {createWFCD,createMarketQuery,createUpdateMonitor}=require('./wfcd');
8
+ const {TrackerError}=require('./client-error');
9
+
10
+ const itemTypes=new Set(['warframes','weapons','items','mods']);
11
+ const id=v=>parameter(v,'identifier');
12
+ function platformOf(value) {
13
+ const p=value||'pc';
14
+ if(!provider.platforms.includes(p))throw new RangeError('Unsupported platform '+p);
15
+ return p;
16
+ }
17
+ function createWarframeTracker({platform='pc',language='en',timeoutMs=15000,cacheDir,githubToken,fetchImpl}={}) {
18
+ const selected=platformOf(platform);
19
+ const {request}=createWarframeClient({language,timeoutMs,fetchImpl});
20
+ const data=createDataStore({
21
+ gameId:'warframe', github:{owner:'NekoSuneProjectsNPM',repo:'game-tracker',branch:'JsonData',directory:'games/warframe'},
22
+ bundledDir:require('node:path').join(__dirname,'..','data'),
23
+ cacheDir, githubToken, fetchImpl
24
+ });
25
+ const catalog=createCatalog(data);
26
+ // Local-first: only query GitHub if a requested character is absent.
27
+ // Existing bundled and cached characters work without any network call.
28
+ async function character(name, options = {}) {
29
+ try { return await catalog.character(name); }
30
+ catch (err) {
31
+ if (err.code !== 'NOT_FOUND') throw err;
32
+ if (options.refresh === false) throw err;
33
+ await data.sync({ signal: options.signal });
34
+ return catalog.character(name);
35
+ }
36
+ }
37
+ // Set refresh:true to discover new records that didn't exist in local JSON.
38
+ async function search(query, options = {}) {
39
+ if (options.refresh === true) await data.sync({ signal: options.signal });
40
+ return catalog.search(query, options);
41
+ }
42
+ const codes=createWarframeCodes({cacheDir,githubToken,fetchImpl});
43
+ const wfcd=createWFCD({cacheDir,githubToken,fetchImpl});
44
+ const worldstate=(field,options={})=>{
45
+ if(typeof field!=='string' || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(field)) throw new TypeError('Invalid worldstate field');
46
+ return request(selected+'/'+field,optionsQuery(options,['filter']));
47
+ };
48
+ const listing=(kind,options={})=>{
49
+ if(!itemTypes.has(kind))throw new RangeError('Unknown item type');
50
+ return request(kind,optionsQuery(options,['language','filter','only','remove']));
51
+ };
52
+ const single=(kind,name,options={})=>{
53
+ if(!itemTypes.has(kind))throw new RangeError('Unknown item type');
54
+ return request(kind+'/'+id(name),optionsQuery(options,['language','filter','only','remove','by']));
55
+ };
56
+ const find=(kind,query,options={})=>{
57
+ if(!itemTypes.has(kind))throw new RangeError('Unknown item type');
58
+ return request(kind+'/search/'+id(query),optionsQuery(options,['language','filter','only','remove','by']));
59
+ };
60
+ const api=Object.freeze({
61
+ health:()=>request('health'),
62
+ worldstate:()=>request(selected),
63
+ worldstateField:(field,options)=>worldstate(field,options),
64
+ profile:(playerId,options)=>request('profile/'+id(playerId),optionsQuery(options,['language','withItem'])),
65
+ profileStats:playerId=>request('profile/'+id(playerId)+'/stats'),
66
+ profileXP:(playerId,options)=>request('profile/'+id(playerId)+'/xpInfo',optionsQuery(options,['withItem'])),
67
+ list:listing,
68
+ get:single,
69
+ search:find,
70
+ warframes:options=>listing('warframes',options),
71
+ weapons:options=>listing('weapons',options),
72
+ items:options=>listing('items',options),
73
+ mods:options=>listing('mods',options),
74
+ warframe:(name,options)=>single('warframes',name,options),
75
+ weapon:(name,options)=>single('weapons',name,options),
76
+ item:(name,options)=>single('items',name,options),
77
+ mod:(name,options)=>single('mods',name,options),
78
+ searchWarframes:(q,options)=>find('warframes',q,options),
79
+ searchWeapons:(q,options)=>find('weapons',q,options),
80
+ searchItems:(q,options)=>find('items',q,options),
81
+ searchMods:(q,options)=>find('mods',q,options),
82
+ drops:()=>request('drops'),
83
+ searchDrops:(query,options)=>request('drops/search/'+id(query),optionsQuery(options,['grouped_by'])),
84
+ rivens:()=>request(selected+'/rivens'),
85
+ searchRivens:query=>request(selected+'/rivens/search/'+id(query)),
86
+ staticData:(key,options)=>request(id(key),optionsQuery(options,['language'])),
87
+ searchStatic:(key,query,options)=>request(id(key)+'/search/'+id(query),optionsQuery(options,['language'])),
88
+ synthTargets:options=>request('synthTargets',optionsQuery(options,['language'])),
89
+ arcanes:options=>request('arcanes',optionsQuery(options,['language'])),
90
+ rss:()=>request('rss'),
91
+ twitter:()=>request('twitter'),
92
+ wfinfoFilteredItems:()=>request('wfinfo/filtered_items'),
93
+ wfinfoPrices:()=>request('wfinfo/prices')
94
+ });
95
+ const fields=Object.fromEntries(provider.worldstateFields.filter(k=>!Object.hasOwn(api,k)).map(k=>[k,options=>worldstate(k,options)]));
96
+ const fullApi=Object.freeze({...api,...fields});
97
+ return Object.freeze({
98
+ api:fullApi,
99
+ data,
100
+ codes,
101
+ wfcd,
102
+ market:createMarketQuery,
103
+ createUpdateMonitor,
104
+ code:codes.get,
105
+ About:()=>data.get('meta.json'),
106
+ search,
107
+ character,
108
+ record:catalog.get,
109
+ item:catalog.item,
110
+ map:catalog.map,
111
+ // live player endpoints remain under api.* to distinguish local records
112
+ getPlayerStats:fullApi.profileStats
113
+ });
114
+ }
115
+ module.exports={createTracker:createWarframeTracker,TrackerError};
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+ const { TrackerError } = require('./client-error');
3
+ const provider = require('./provider');
4
+
5
+ function parameter(value, name) {
6
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(name + ' must be nonempty text');
7
+ return encodeURIComponent(value.trim());
8
+ }
9
+ function optionsQuery(options, allowed) {
10
+ if (options === undefined || options === null) return undefined;
11
+ if (typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Options must be an object');
12
+ const params = {};
13
+ for (const [key,value] of Object.entries(options)) {
14
+ if (!allowed.includes(key)) throw new TypeError('Unsupported query option: ' + key);
15
+ if (value !== undefined && value !== null) {
16
+ if (!['string','number','boolean'].includes(typeof value)) throw new TypeError('Invalid query option: ' + key);
17
+ params[key]=String(value);
18
+ }
19
+ }
20
+ return params;
21
+ }
22
+ function createWarframeClient({ timeoutMs = 15000, fetchImpl = globalThis.fetch, language = 'en' } = {}) {
23
+ if (typeof fetchImpl !== 'function') throw new TypeError('Node.js 18+ fetch is required');
24
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1) throw new TypeError('timeoutMs must be a positive integer');
25
+ if (typeof language !== 'string' || !/^[a-z]{2}$/.test(language)) throw new TypeError('language must be a 2-letter code');
26
+ async function request(route, query, { signal } = {}) {
27
+ if (typeof route !== 'string' || !/^[a-zA-Z0-9_/%.-]+$/.test(route) || route.startsWith('/'))
28
+ throw new TypeError('Invalid internal API route');
29
+ const url = new URL(route, provider.baseUrl);
30
+ if (url.origin !== new URL(provider.baseUrl).origin) throw new TypeError('API origin mismatch');
31
+ for (const [k,v] of Object.entries(query || {})) url.searchParams.set(k,String(v));
32
+ const controller = new AbortController();
33
+ const timer = setTimeout(() => controller.abort(),timeoutMs);
34
+ const onAbort = () => controller.abort();
35
+ if (signal) {
36
+ if (signal.aborted) controller.abort();
37
+ else signal.addEventListener('abort',onAbort,{once:true});
38
+ }
39
+ try {
40
+ const response = await fetchImpl(url,{
41
+ headers:{Accept:'application/json','Accept-Language':language,'User-Agent':'nekosuneprojects-warframe-api'},
42
+ signal:controller.signal
43
+ });
44
+ if (!response.ok) throw new TrackerError('WarframeStat.us HTTP '+response.status,{status:response.status});
45
+ if (response.status===204) return null;
46
+ return await response.json();
47
+ } catch (cause) {
48
+ if (cause instanceof TrackerError) throw cause;
49
+ throw new TrackerError('WarframeStat.us request failed',{cause});
50
+ } finally {
51
+ clearTimeout(timer);
52
+ if (signal) signal.removeEventListener('abort',onAbort);
53
+ }
54
+ }
55
+ return Object.freeze({request,parameter,optionsQuery});
56
+ }
57
+ module.exports={createWarframeClient,parameter,optionsQuery};
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+ const { createDataStore } = require('./data-store');
4
+
5
+ const FILE = 'warframe.json';
6
+ const GROUPS = Object.freeze({ active:'Active', expired:'Expired', unknown:'Unknown' });
7
+ const normalise = text => String(text ?? '').normalize('NFKC').trim().toLowerCase();
8
+ function validate(source) {
9
+ if (!source || typeof source !== 'object' || Array.isArray(source) ||
10
+ !Object.values(GROUPS).every(group => Array.isArray(source[group]))) {
11
+ throw new TypeError('Invalid Warframe codes JSON: Active, Expired and Unknown arrays are required');
12
+ }
13
+ return source;
14
+ }
15
+ function createWarframeCodes({cacheDir,githubToken,fetchImpl}={}) {
16
+ const storage = createDataStore({
17
+ gameId:'warframe-codes',
18
+ cacheDir:cacheDir ? path.join(path.resolve(cacheDir),'codes') : undefined,
19
+ bundledDir:path.join(__dirname,'..','data','codes'),
20
+ github:{owner:'NekoSuneProjectsNPM',repo:'game-tracker',branch:'JsonData',directory:'codes/games'},
21
+ syncFiles:[FILE],
22
+ githubToken,fetchImpl
23
+ });
24
+ async function raw() { return validate(await storage.get(FILE)); }
25
+ async function list({status,category,claimType,hasCode,limit}={}) {
26
+ if (status !== undefined && !Object.hasOwn(GROUPS,status)) throw new RangeError('status must be active, expired, or unknown');
27
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) throw new RangeError('limit must be a positive integer');
28
+ const doc=await raw();
29
+ const keys=status ? [GROUPS[status]] : Object.values(GROUPS);
30
+ let entries=keys.flatMap(key=>doc[key]);
31
+ if (category !== undefined) entries=entries.filter(item=>normalise(item.category)===normalise(category));
32
+ if (claimType !== undefined) entries=entries.filter(item=>normalise(item.claimType)===normalise(claimType));
33
+ if (hasCode !== undefined) {
34
+ if (typeof hasCode !== 'boolean') throw new TypeError('hasCode must be boolean');
35
+ entries=entries.filter(item=>Boolean(typeof item.code==='string'&&item.code.trim())===hasCode);
36
+ }
37
+ return limit ? entries.slice(0,limit) : entries;
38
+ }
39
+ async function search(query,options={}) {
40
+ if(typeof query!=='string'||!query.trim()) throw new TypeError('Search query is required');
41
+ const needle=normalise(query);
42
+ const entries=await list(options);
43
+ const results=entries.filter(record=>{
44
+ const fields=[record.code,record.id,record.category,record.claimType,record.notes,record.redeemLocation,
45
+ ...(Array.isArray(record.items)?record.items.map(item=>item?.name):[])];
46
+ return fields.some(value=>normalise(value).includes(needle));
47
+ });
48
+ return {query,total:results.length,results,message:results.length?
49
+ 'Found '+results.length+' Warframe code-related entries for "'+query+'".':
50
+ 'We don\'t have anything related to "'+query+'" in Warframe codes.'};
51
+ }
52
+ async function get(key) {
53
+ if((typeof key!=='string' && typeof key!=='number') || !String(key).trim()) throw new TypeError('Code or ID is required');
54
+ const needle=normalise(key);
55
+ const results=(await list()).filter(item=>normalise(item.id)===needle || (item.code && normalise(item.code)===needle));
56
+ if(results.length===0) {
57
+ const err=new Error('No Warframe code entry found: '+key); err.code='NOT_FOUND';throw err;
58
+ }
59
+ if(results.length>1) {
60
+ const err=new Error('Multiple Warframe code entries match: '+key);err.code='AMBIGUOUS';err.results=results;throw err;
61
+ }
62
+ return results[0];
63
+ }
64
+ async function summary() {
65
+ const doc=await raw(); const groups={};let total=0;let redeemable=0;
66
+ for(const [status,key] of Object.entries(GROUPS)) {
67
+ const entries=doc[key];
68
+ const withCode=entries.filter(item=>typeof item.code==='string' && item.code.trim()).length;
69
+ groups[status]={total:entries.length,withCode,withoutCode:entries.length-withCode};
70
+ total+=entries.length;redeemable+=withCode;
71
+ }
72
+ return {total,withCode:redeemable,withoutCode:total-redeemable,groups};
73
+ }
74
+ return Object.freeze({
75
+ raw,list,active:options=>list({...options,status:'active'}),
76
+ expired:options=>list({...options,status:'expired'}),
77
+ unknown:options=>list({...options,status:'unknown'}),
78
+ search,get,summary,sync:options=>storage.sync(options),
79
+ cacheDir:storage.cacheDir
80
+ });
81
+ }
82
+ module.exports={createWarframeCodes};