@nekosuneprojects/honkaistarrail-api 0.0.0-stage → 1.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/LICENSE +21 -0
- package/README.md +75 -3
- package/data/characters/acheron.json +13 -0
- package/data/characters/aglaea.json +13 -0
- package/data/characters/anaxa.json +13 -0
- package/data/characters/archer.json +29 -0
- package/data/characters/argenti.json +13 -0
- package/data/characters/arlan.json +13 -0
- package/data/characters/ashveil.json +13 -0
- package/data/characters/asta.json +13 -0
- package/data/characters/aventurine.json +13 -0
- package/data/characters/aventurinewaveflair.json +14 -0
- package/data/characters/bailu.json +13 -0
- package/data/characters/blackswan.json +13 -0
- package/data/characters/blade.json +13 -0
- package/data/characters/boothill.json +13 -0
- package/data/characters/bronya.json +13 -0
- package/data/characters/castorice.json +13 -0
- package/data/characters/cerydra.json +13 -0
- package/data/characters/cipher.json +13 -0
- package/data/characters/clara.json +13 -0
- package/data/characters/constance.json +13 -0
- package/data/characters/cyrene.json +13 -0
- package/data/characters/danheng.json +13 -0
- package/data/characters/danhengimbibitorlunae.json +29 -0
- package/data/characters/danhengpermansorterrae.json +29 -0
- package/data/characters/drratio.json +13 -0
- package/data/characters/evanescia.json +29 -0
- package/data/characters/evernight.json +29 -0
- package/data/characters/feixiao.json +13 -0
- package/data/characters/firefly.json +13 -0
- package/data/characters/fugue.json +29 -0
- package/data/characters/fuxuan.json +13 -0
- package/data/characters/gallagher.json +13 -0
- package/data/characters/gepard.json +13 -0
- package/data/characters/gilgamesh.json +29 -0
- package/data/characters/guinaifen.json +13 -0
- package/data/characters/hanya.json +13 -0
- package/data/characters/herta.json +13 -0
- package/data/characters/himeko.json +14 -0
- package/data/characters/himekonova.json +29 -0
- package/data/characters/hook.json +13 -0
- package/data/characters/huohuo.json +13 -0
- package/data/characters/hyacine.json +13 -0
- package/data/characters/hysilens.json +13 -0
- package/data/characters/jade.json +13 -0
- package/data/characters/jiaoqiu.json +13 -0
- package/data/characters/jingliu.json +13 -0
- package/data/characters/jingyuan.json +13 -0
- package/data/characters/kafka.json +13 -0
- package/data/characters/lingsha.json +13 -0
- package/data/characters/lisushang.json +13 -0
- package/data/characters/luka.json +13 -0
- package/data/characters/luocha.json +13 -0
- package/data/characters/lynx.json +13 -0
- package/data/characters/march7th.json +13 -0
- package/data/characters/misha.json +13 -0
- package/data/characters/mortenaxblade.json +29 -0
- package/data/characters/moze.json +13 -0
- package/data/characters/mydei.json +13 -0
- package/data/characters/natasha.json +13 -0
- package/data/characters/pearl.json +15 -0
- package/data/characters/pela.json +13 -0
- package/data/characters/phainon.json +13 -0
- package/data/characters/qingque.json +13 -0
- package/data/characters/rappa.json +13 -0
- package/data/characters/rintohsaka.json +29 -0
- package/data/characters/robin.json +13 -0
- package/data/characters/robinsummeretto.json +14 -0
- package/data/characters/ruanmei.json +13 -0
- package/data/characters/saber.json +29 -0
- package/data/characters/sampo.json +13 -0
- package/data/characters/seele.json +13 -0
- package/data/characters/serval.json +13 -0
- package/data/characters/silverwolf.json +13 -0
- package/data/characters/silverwolflv999.json +29 -0
- package/data/characters/sparkle.json +13 -0
- package/data/characters/sparxie.json +29 -0
- package/data/characters/sunday.json +29 -0
- package/data/characters/thedahlia.json +29 -0
- package/data/characters/theherta.json +29 -0
- package/data/characters/tingyun.json +13 -0
- package/data/characters/topaz.json +13 -0
- package/data/characters/topaznumby.json +29 -0
- package/data/characters/trailblazer.json +29 -0
- package/data/characters/tribbie.json +13 -0
- package/data/characters/welt.json +29 -0
- package/data/characters/weltyang.json +13 -0
- package/data/characters/xueyi.json +13 -0
- package/data/characters/yanqing.json +13 -0
- package/data/characters/yaoguang.json +13 -0
- package/data/characters/yukong.json +13 -0
- package/data/characters/yunli.json +13 -0
- package/data/codes/honkai-star-rail.json +7576 -0
- package/data/meta.json +14 -0
- package/index.js +5 -0
- package/package.json +41 -6
- package/src/catalog.js +105 -0
- package/src/codes.js +24 -0
- package/src/data-store.js +138 -0
- package/src/enka.js +58 -0
- package/src/tracker.js +20 -0
package/data/meta.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Honkai: Star Rail",
|
|
3
|
+
"title": "A turn-based sci-fi/fantasy RPG following the Astral Express crew across the galaxy.",
|
|
4
|
+
"engine": "Unity",
|
|
5
|
+
"Platform": "PC, PlayStation 5, iOS, Android",
|
|
6
|
+
"Release date": "April 26, 2023 (Windows, iOS, Android) | October 11, 2023 (PS5)",
|
|
7
|
+
"Genre": "Turn-Based RPG, Gacha",
|
|
8
|
+
"developers": "miHoYo",
|
|
9
|
+
"publisher": "miHoYo (China) / HoYoverse (Global)",
|
|
10
|
+
"aliases": "HSR, Star Rail",
|
|
11
|
+
"image": "https://upload.wikimedia.org/wikipedia/en/thumb/8/8e/Honkai_Star_Rail_logo.svg/250px-Honkai_Star_Rail_logo.svg.png",
|
|
12
|
+
"description": "A free-to-play turn-based RPG by HoYoverse, following the crew of the Astral Express as they travel the stars.",
|
|
13
|
+
"url": "https://en.wikipedia.org/wiki/Honkai:_Star_Rail"
|
|
14
|
+
}
|
package/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@nekosuneprojects/honkaistarrail-api",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@nekosuneprojects/honkaistarrail-api",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Local-first Honkai: Star Rail game and character JSON, redeem-code archive, and optional Enka.Network HSR public UID profiles.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"check": "node scripts/validate-package.js",
|
|
12
|
+
"test": "node --test",
|
|
13
|
+
"prepack": "npm run check && npm test"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/NekoSuneProjectsNPM/game-tracker.git"
|
|
18
|
+
},
|
|
19
|
+
"author": "NekoSuneVR",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"keywords": [
|
|
22
|
+
"honkai-star-rail",
|
|
23
|
+
"honkaistarrail",
|
|
24
|
+
"hsr",
|
|
25
|
+
"redeem-codes",
|
|
26
|
+
"characters",
|
|
27
|
+
"enka-network",
|
|
28
|
+
"local-json"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"index.js",
|
|
32
|
+
"src/",
|
|
33
|
+
"data/",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org/"
|
|
40
|
+
}
|
|
41
|
+
}
|
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 };
|
package/src/codes.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path=require('node:path');
|
|
3
|
+
const {createDataStore}=require('./data-store');
|
|
4
|
+
const FILE='honkai-star-rail.json', GROUPS={active:'Active',expired:'Expired',unknown:'Unknown'};
|
|
5
|
+
const norm=x=>String(x??'').trim().toLowerCase();
|
|
6
|
+
function createCodes({cacheDir,githubToken,fetchImpl}={}){
|
|
7
|
+
const data=createDataStore({gameId:'honkaistarrail-codes',cacheDir,githubToken,fetchImpl,bundledDir:path.join(__dirname,'..','data','codes'),github:{owner:'NekoSuneProjectsNPM',repo:'game-tracker',branch:'JsonData',directory:'codes/games'},syncFiles:[FILE]});
|
|
8
|
+
async function snapshot({refresh=false,signal}={}){if(refresh)await data.sync({signal});const doc=await data.get(FILE);for(const k of Object.values(GROUPS))if(!Array.isArray(doc[k]))throw Error('Invalid HSR code archive: '+k);return doc;}
|
|
9
|
+
function filter(rows,{hasCode,status,category,limit}={}){
|
|
10
|
+
if(hasCode!==undefined&&typeof hasCode!=='boolean')throw new TypeError('hasCode must be boolean');
|
|
11
|
+
if(status!==undefined&&!Object.hasOwn(GROUPS,norm(status)))throw new RangeError('Invalid status');
|
|
12
|
+
if(limit!==undefined&&(!Number.isInteger(limit)||limit<1||limit>10000))throw new RangeError('limit must be 1–10000');
|
|
13
|
+
return rows.filter(v=>(hasCode===undefined||Boolean(v.code)===hasCode)&&(status===undefined||norm(v.status)===norm(status))&&(category===undefined||norm(v.category)===norm(category))).slice(0,limit??rows.length);
|
|
14
|
+
}
|
|
15
|
+
async function all(options={}){const doc=await snapshot(options);return filter([...doc.Active,...doc.Expired,...doc.Unknown],options);}
|
|
16
|
+
async function group(groupName,options={}){const doc=await snapshot(options);return filter(doc[GROUPS[groupName]],options);}
|
|
17
|
+
async function get(value,options={}){if(typeof value!=='string'&&!Number.isSafeInteger(value))throw new TypeError('Code or numeric ID required');const needle=norm(value);if(!needle)throw new TypeError('Nonempty code required');const entry=(await all(options)).find(v=>norm(v.code)===needle||norm(v.id)===needle);if(!entry)throw Object.assign(new Error('Code not found'),{code:'NOT_FOUND'});return entry;}
|
|
18
|
+
async function search(query,options={}){if(typeof query!=='string'||!query.trim())throw new TypeError('Search text required');const needle=norm(query);const results=(await all(options)).filter(v=>[v.code,v.status,v.category,...(v.items||[]).map(item=>item.name)].some(x=>norm(x).includes(needle)));return {query,total:results.length,results};}
|
|
19
|
+
// The archive contains scraped names and stale "active" statuses.
|
|
20
|
+
// Therefore verifiedActive intentionally returns no unverified records.
|
|
21
|
+
async function verifiedActive(){return [];}
|
|
22
|
+
return Object.freeze({snapshot,all,active:opts=>group('active',opts),expired:opts=>group('expired',opts),unknown:opts=>group('unknown',opts),get,search,verifiedActive,data,sync:opts=>data.sync(opts)});
|
|
23
|
+
}
|
|
24
|
+
module.exports={createCodes};
|
|
@@ -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 };
|
package/src/enka.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const BASE='https://enka.network/api/hsr/uid/';
|
|
3
|
+
class EnkaError extends Error {
|
|
4
|
+
constructor(message,{code='ENKA_ERROR',status=null,uid=null,retryAfter=null,cause}={}){
|
|
5
|
+
super(message,{cause});this.name='EnkaError';Object.assign(this,{code,status,uid,retryAfter});
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function validUid(input){
|
|
9
|
+
const uid=String(input??'').trim();
|
|
10
|
+
if(!/^[1-9][0-9]{8}$/.test(uid))throw new TypeError('HSR UID must be a nine-digit number');
|
|
11
|
+
return uid;
|
|
12
|
+
}
|
|
13
|
+
function createEnka({fetchImpl=globalThis.fetch,timeoutMs=12000,userAgent='NekoSuneProjects-honkaistarrail-api/1.0 (https://github.com/NekoSuneProjectsNPM/game-tracker)',clock=()=>Date.now()}={}){
|
|
14
|
+
if(typeof fetchImpl!=='function')throw new TypeError('fetch required');
|
|
15
|
+
if(typeof clock!=='function')throw new TypeError('clock must be a function');
|
|
16
|
+
if(typeof userAgent!=='string'||!userAgent.trim())throw new TypeError('userAgent required');
|
|
17
|
+
if(!Number.isInteger(timeoutMs)||timeoutMs<100||timeoutMs>60000)throw new RangeError('timeoutMs must be 100–60000');
|
|
18
|
+
const cache=new Map(),inflight=new Map();
|
|
19
|
+
function clearCache(uid){if(uid===undefined){cache.clear();return;}cache.delete(validUid(uid));}
|
|
20
|
+
async function player(input,{refresh=false,signal}={}){
|
|
21
|
+
const uid=validUid(input);
|
|
22
|
+
if(typeof refresh!=='boolean')throw new TypeError('refresh must be boolean');
|
|
23
|
+
if(signal?.aborted)throw new EnkaError('Aborted',{code:'ABORTED',uid});
|
|
24
|
+
const cached=cache.get(uid);
|
|
25
|
+
if(!refresh&&cached&&cached.expires>clock())return cached.value;
|
|
26
|
+
if(!refresh&&inflight.has(uid))return inflight.get(uid);
|
|
27
|
+
const ctrl=new AbortController();
|
|
28
|
+
const abort=()=>ctrl.abort();
|
|
29
|
+
if(signal)signal.addEventListener('abort',abort,{once:true});
|
|
30
|
+
const timer=setTimeout(abort,timeoutMs);
|
|
31
|
+
const job=(async()=>{
|
|
32
|
+
try{
|
|
33
|
+
let res;
|
|
34
|
+
try{res=await fetchImpl(BASE+uid,{method:'GET',headers:{Accept:'application/json','User-Agent':userAgent},signal:ctrl.signal});}
|
|
35
|
+
catch(cause){throw new EnkaError('Enka fetch failed',{code:ctrl.signal.aborted?'ABORTED':'NETWORK_ERROR',uid,cause});}
|
|
36
|
+
if(!res||typeof res.ok!=='boolean'||!Number.isInteger(res.status))throw new EnkaError('Invalid HTTP response',{code:'INVALID_RESPONSE',uid});
|
|
37
|
+
if(!res.ok){
|
|
38
|
+
const codes={400:'INVALID_UID',404:'PLAYER_NOT_FOUND',424:'GAME_MAINTENANCE',429:'RATE_LIMITED',500:'SERVER_ERROR',503:'SERVICE_UNAVAILABLE'};
|
|
39
|
+
const retry=res.headers?.get?.('retry-after');
|
|
40
|
+
throw new EnkaError('Enka HTTP '+res.status,{code:codes[res.status]||'HTTP_ERROR',status:res.status,uid,retryAfter:retry&&/^\d+$/.test(retry)?Number(retry):null});
|
|
41
|
+
}
|
|
42
|
+
let json;
|
|
43
|
+
try{json=await res.json();}catch(cause){throw new EnkaError('Invalid Enka JSON',{code:'INVALID_JSON',status:res.status,uid,cause});}
|
|
44
|
+
if(!json||typeof json!=='object'||Array.isArray(json)||!json.detailInfo||typeof json.detailInfo!=='object'||Array.isArray(json.detailInfo))throw new EnkaError('Missing HSR detailInfo',{code:'INVALID_RESPONSE',status:res.status,uid});
|
|
45
|
+
const ttl=Number(json.ttl);
|
|
46
|
+
if(Number.isFinite(ttl)&&ttl>0)cache.set(uid,{value:json,expires:clock()+Math.min(ttl,3600)*1000});
|
|
47
|
+
else cache.delete(uid);
|
|
48
|
+
return json;
|
|
49
|
+
}finally{clearTimeout(timer);if(signal)signal.removeEventListener('abort',abort);}
|
|
50
|
+
})();
|
|
51
|
+
inflight.set(uid,job);
|
|
52
|
+
try{return await job;}finally{if(inflight.get(uid)===job)inflight.delete(uid);}
|
|
53
|
+
}
|
|
54
|
+
async function avatars(uid,options={}){const result=await player(uid,options);return Array.isArray(result.detailInfo.avatarDetailList)?result.detailInfo.avatarDetailList:[];}
|
|
55
|
+
async function info(uid,options={}){return (await player(uid,options)).detailInfo;}
|
|
56
|
+
return Object.freeze({get:player,player,info,avatars,clearCache,endpoint:BASE});
|
|
57
|
+
}
|
|
58
|
+
module.exports={createEnka,EnkaError};
|
package/src/tracker.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path=require('node:path');
|
|
3
|
+
const {createDataStore}=require('./data-store');
|
|
4
|
+
const {createCatalog}=require('./catalog');
|
|
5
|
+
const {createCodes}=require('./codes');
|
|
6
|
+
const {createEnka}=require('./enka');
|
|
7
|
+
function createHonkaiStarRail({cacheDir,codesCacheDir,githubToken,fetchImpl,enkaOptions}={}){
|
|
8
|
+
const data=createDataStore({gameId:'honkaistarrail',cacheDir,githubToken,fetchImpl,bundledDir:path.join(__dirname,'..','data'),github:{owner:'NekoSuneProjectsNPM',repo:'game-tracker',branch:'JsonData',directory:'games/honkaistarrail'}});
|
|
9
|
+
const catalog=createCatalog(data);
|
|
10
|
+
const codes=createCodes({cacheDir:codesCacheDir,githubToken,fetchImpl});
|
|
11
|
+
const enka=createEnka({fetchImpl,...enkaOptions});
|
|
12
|
+
async function character(id,{refresh=false,signal}={}){
|
|
13
|
+
try{return await catalog.get('characters',id);}
|
|
14
|
+
catch(err){if(err.code!=='NOT_FOUND'||!refresh)throw err;await data.sync({signal});return catalog.get('characters',id);}
|
|
15
|
+
}
|
|
16
|
+
async function search(query,{refresh=false,signal,...opts}={}){if(refresh)await data.sync({signal});return catalog.search(query,opts);}
|
|
17
|
+
const characters=Object.freeze({get:character,list:async()=>Promise.all((await data.list()).filter(f=>f.startsWith('characters/')&&f.endsWith('.json')).map(f=>data.get(f))),search:(q,opts={})=>search(q,{...opts,category:'characters'})});
|
|
18
|
+
return Object.freeze({About:()=>data.get('meta.json'),character,characters,search,codes,data,enka,player:enka.player});
|
|
19
|
+
}
|
|
20
|
+
module.exports={createHonkaiStarRail};
|