@nekosuneprojects/warframe-api 1.0.0 → 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.
- package/LICENSE +21 -21
- package/README.md +298 -298
- package/data/characters/amesha.json +226 -226
- package/data/characters/ash.json +1354 -1354
- package/data/characters/ashprime.json +1994 -1994
- package/data/characters/atlas.json +916 -916
- package/data/characters/atlasprime.json +1632 -1632
- package/data/characters/banshee.json +1036 -1036
- package/data/characters/bansheeprime.json +1750 -1750
- package/data/characters/baruuk.json +555 -555
- package/data/characters/baruukprime.json +1298 -1298
- package/data/characters/bonewidow.json +354 -354
- package/data/characters/caliban.json +434 -434
- package/data/characters/calibanprime.json +683 -683
- package/data/characters/chroma.json +1344 -1344
- package/data/characters/chromaprime.json +1977 -1977
- package/data/characters/citrine.json +220 -220
- package/data/characters/citrineprime.json +62 -62
- package/data/characters/cyte09.json +218 -218
- package/data/characters/dagath.json +194 -194
- package/data/characters/dante.json +223 -223
- package/data/characters/elytron.json +300 -300
- package/data/characters/ember.json +1209 -1209
- package/data/characters/emberprime.json +1678 -1678
- package/data/characters/equinox.json +958 -958
- package/data/characters/equinoxprime.json +1644 -1644
- package/data/characters/excalibur.json +1603 -1603
- package/data/characters/excaliburprime.json +1524 -1524
- package/data/characters/excaliburumbra.json +397 -397
- package/data/characters/follie.json +246 -246
- package/data/characters/frost.json +1330 -1330
- package/data/characters/frostprime.json +1686 -1686
- package/data/characters/gara.json +925 -925
- package/data/characters/garaprime.json +1494 -1494
- package/data/characters/garuda.json +851 -851
- package/data/characters/garudaprime.json +1449 -1449
- package/data/characters/gauss.json +506 -506
- package/data/characters/gaussprime.json +1077 -1077
- package/data/characters/grendel.json +463 -463
- package/data/characters/grendelprime.json +772 -772
- package/data/characters/gyre.json +381 -381
- package/data/characters/gyreprime.json +403 -403
- package/data/characters/harrow.json +916 -916
- package/data/characters/harrowprime.json +1700 -1700
- package/data/characters/helminth.json +220 -220
- package/data/characters/hildryn.json +627 -627
- package/data/characters/hildrynprime.json +996 -996
- package/data/characters/hydroid.json +1028 -1028
- package/data/characters/hydroidprime.json +1621 -1621
- package/data/characters/inaros.json +976 -976
- package/data/characters/inarosprime.json +1721 -1721
- package/data/characters/itzal.json +465 -465
- package/data/characters/ivara.json +1349 -1349
- package/data/characters/ivaraprime.json +1921 -1921
- package/data/characters/jade.json +240 -240
- package/data/characters/khora.json +804 -804
- package/data/characters/khoraprime.json +1403 -1403
- package/data/characters/koumei.json +221 -221
- package/data/characters/kullervo.json +191 -191
- package/data/characters/lavos.json +511 -511
- package/data/characters/lavosprime.json +711 -711
- package/data/characters/limbo.json +1363 -1363
- package/data/characters/limboprime.json +2084 -2084
- package/data/characters/loki.json +1209 -1209
- package/data/characters/lokiprime.json +1457 -1457
- package/data/characters/mag.json +2089 -2089
- package/data/characters/magprime.json +2532 -2532
- package/data/characters/mesa.json +1195 -1195
- package/data/characters/mesaprime.json +2059 -2059
- package/data/characters/mirage.json +1561 -1561
- package/data/characters/mirageprime.json +2257 -2257
- package/data/characters/narin.json +43 -43
- package/data/characters/nekros.json +1235 -1235
- package/data/characters/nekrosprime.json +1882 -1882
- package/data/characters/nezha.json +868 -868
- package/data/characters/nezhaprime.json +1695 -1695
- package/data/characters/nidus.json +1027 -1027
- package/data/characters/nidusprime.json +1706 -1706
- package/data/characters/nokko.json +308 -308
- package/data/characters/nova.json +1226 -1226
- package/data/characters/novaprime.json +1667 -1667
- package/data/characters/nyx.json +1610 -1610
- package/data/characters/nyxprime.json +1967 -1967
- package/data/characters/oberon.json +1249 -1249
- package/data/characters/oberonprime.json +1872 -1872
- package/data/characters/octavia.json +1049 -1049
- package/data/characters/octaviaprime.json +1735 -1735
- package/data/characters/odonata.json +336 -336
- package/data/characters/odonataprime.json +787 -787
- package/data/characters/oraxia.json +246 -246
- package/data/characters/orionsirius.json +134 -134
- package/data/characters/protea.json +521 -521
- package/data/characters/proteaprime.json +992 -992
- package/data/characters/qorvex.json +214 -214
- package/data/characters/revenant.json +931 -931
- package/data/characters/revenantprime.json +1494 -1494
- package/data/characters/rhino.json +1344 -1344
- package/data/characters/rhinoprime.json +1675 -1675
- package/data/characters/saryn.json +1210 -1210
- package/data/characters/sarynprime.json +2042 -2042
- package/data/characters/sevagoth.json +478 -478
- package/data/characters/sevagothprime.json +910 -910
- package/data/characters/siriusorion.json +247 -247
- package/data/characters/styanax.json +320 -320
- package/data/characters/styanaxprime.json +321 -321
- package/data/characters/temple.json +271 -271
- package/data/characters/titania.json +1227 -1227
- package/data/characters/titaniaprime.json +2000 -2000
- package/data/characters/trinity.json +1097 -1097
- package/data/characters/trinityprime.json +1849 -1849
- package/data/characters/uriel.json +215 -215
- package/data/characters/valkyr.json +1165 -1165
- package/data/characters/valkyrprime.json +1969 -1969
- package/data/characters/vauban.json +1067 -1067
- package/data/characters/vaubanprime.json +1614 -1614
- package/data/characters/voidrig.json +355 -355
- package/data/characters/volt.json +1324 -1324
- package/data/characters/voltprime.json +1843 -1843
- package/data/characters/voruna.json +293 -293
- package/data/characters/vorunaprime.json +431 -431
- package/data/characters/wisp.json +824 -824
- package/data/characters/wispprime.json +908 -908
- package/data/characters/wukong.json +927 -927
- package/data/characters/wukongprime.json +1754 -1754
- package/data/characters/xaku.json +620 -620
- package/data/characters/xakuprime.json +879 -879
- package/data/characters/yareli.json +409 -409
- package/data/characters/yareliprime.json +683 -683
- package/data/characters/zephyr.json +1149 -1149
- package/data/characters/zephyrprime.json +1864 -1864
- package/data/codes/warframe.json +20824 -20824
- package/data/meta.json +14 -14
- package/index.js +11 -11
- package/package.json +50 -50
- package/src/catalog.js +105 -105
- package/src/client-error.js +9 -9
- package/src/data-source.js +7 -7
- package/src/data-store.js +138 -138
- package/src/provider.js +22 -22
- package/src/tracker.js +115 -115
- package/src/warframe-client.js +57 -57
- package/src/warframe-codes.js +82 -82
- package/src/wfcd.js +93 -93
package/src/data-store.js
CHANGED
|
@@ -1,138 +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 };
|
|
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 };
|
package/src/provider.js
CHANGED
|
@@ -1,22 +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
|
-
});
|
|
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
CHANGED
|
@@ -1,115 +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};
|
|
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};
|