neekuro 2.1.0 → 2.3.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.
@@ -1,47 +1,313 @@
1
- /**
2
- * @typedef {import('../typings').SfwCategories} SfwCategories
3
- * @typedef {import('../typings').GifMap} GifMap
4
- */
5
-
6
- const APIClient = require('../rest/APIClient.js');
7
- const NekoError = require('./errors/Error.js');
8
- const NekoGif = require('./package/NekoGif.js');
9
- const { getToken } = require('./user/BaseUser.js');
10
-
11
- // url base de la api para SFW
12
- const base = 'https://www.nexatdc.work.gd/api/sfw'
13
-
14
- /**
15
- * Clase SFW
16
- * No se necesita instanciar para acceder a sus metodos
17
- */
18
- class SFW {
19
- /**
20
- * Funcion principal para obtener gifs de cierta categoria
21
- * @template {SfwCategories} T
22
- * @param {T} cat corresponde a la subcategoria de SFW
23
- * @param {GifMap[T]} gif corresponde al gif que quieras obtener por categoria
24
- * @returns {Promise<NekoGif>}
25
- */
26
- static async getGif(cat, gif) {
27
- if (!cat) throw new NekoError('NoInput', 'el parametro <cat> es requerido');
28
- if (!gif) throw new NekoError('NoInput', 'el parametro <gif> es requerido');
29
-
30
- const token = getToken();
31
- if (!token) throw new NekoError('NoToken', 'Debes establecer un token antes de solicitar un GIF');
32
- let solicitud = new APIClient(base, { token }), response = null;
33
- switch (cat) {
34
- case 'action':
35
- response = solicitud.get('/action/' + gif + '?random=true')
36
- break;
37
- case 'reaction':
38
- response = solicitud.get('/reaction/' + gif + '?random=true')
39
- break;
40
- default:
41
- throw new NekoError('InvalidInput', 'la categoria que ingresas es invalida')
42
- }
43
- return new NekoGif(await response);
44
- }
45
- }
46
-
47
- module.exports = SFW
1
+ 'use strict';
2
+
3
+ /**
4
+ * @typedef {import('../typings').SfwCategories} SfwCategories
5
+ * @typedef {import('../typings').GifMap} GifMap
6
+ * @typedef {import('../typings').GifData} GifData
7
+ * @typedef {import('../typings').GifListResponse} GifListResponse
8
+ */
9
+
10
+ const APIClient = require('../rest/APIClient.js');
11
+ const NekoError = require('./errors/Error.js');
12
+ const NekoGif = require('./package/NekoGif.js');
13
+ const { getToken, getTokenVersion } = require('./user/BaseUser.js');
14
+
15
+ const DEFAULT_BASE_URL = 'https://www.nexatdc.work.gd/api/sfw';
16
+ const DEFAULT_CACHE_TTL = 5 * 60 * 1000;
17
+ const MAX_CACHE_ENTRIES = 100;
18
+
19
+ let baseURL = DEFAULT_BASE_URL;
20
+ let cacheTTL = DEFAULT_CACHE_TTL;
21
+ let globalCacheVersion = 0;
22
+
23
+ /** @type {Map<string, {items: GifData[], expiresAt: number, tokenVersion: number}>} */
24
+ const gifCache = new Map();
25
+ /** @type {Map<string, {promise: Promise<GifData[]>, tokenVersion: number}>} */
26
+ const gifRequests = new Map();
27
+ /** @type {Map<string, number>} */
28
+ const cacheVersions = new Map();
29
+ /** @type {Map<string, number>} */
30
+ const lastSelections = new Map();
31
+
32
+ /** Cliente para obtener imágenes SFW. */
33
+ class SFW {
34
+ /**
35
+ * Obtiene un GIF aleatorio. La lista puede provenir de caché, pero la
36
+ * selección se realiza nuevamente en cada llamada.
37
+ * @template {SfwCategories} T
38
+ * @param {T} category categoría principal
39
+ * @param {GifMap[T]} gif subcategoría solicitada
40
+ * @returns {Promise<NekoGif>}
41
+ */
42
+ static async getGif(category, gif) {
43
+ const key = getCacheKey(category, gif);
44
+ const items = await getGifData(category, gif, key);
45
+ const previousIndex = lastSelections.get(key);
46
+ const index = getRandomIndex(items.length, previousIndex);
47
+
48
+ lastSelections.set(key, index);
49
+ return createNekoGif(items[index]);
50
+ }
51
+
52
+ /**
53
+ * Obtiene todos los GIF disponibles para una subcategoría.
54
+ * Se devuelven instancias nuevas para impedir que el consumidor modifique
55
+ * accidentalmente los datos conservados en caché.
56
+ * @template {SfwCategories} T
57
+ * @param {T} category categoría principal
58
+ * @param {GifMap[T]} gif subcategoría solicitada
59
+ * @returns {Promise<NekoGif[]>}
60
+ */
61
+ static async getGifs(category, gif) {
62
+ const key = getCacheKey(category, gif);
63
+ const items = await getGifData(category, gif, key);
64
+ return items.map(createNekoGif);
65
+ }
66
+
67
+ /**
68
+ * Limpia toda la caché, una categoría principal o una subcategoría.
69
+ * @template {SfwCategories} T
70
+ * @param {T} [category]
71
+ * @param {GifMap[T]} [gif]
72
+ */
73
+ static clearCache(category, gif) {
74
+ if (category === undefined) {
75
+ globalCacheVersion += 1;
76
+ gifCache.clear();
77
+ gifRequests.clear();
78
+ cacheVersions.clear();
79
+ lastSelections.clear();
80
+ return;
81
+ }
82
+
83
+ validateCategory(category);
84
+ if (gif === undefined) {
85
+ const prefix = `${category}:`;
86
+ for (const key of collectKnownKeys(prefix)) clearCacheKey(key);
87
+ return;
88
+ }
89
+
90
+ clearCacheKey(getCacheKey(category, gif));
91
+ }
92
+
93
+ /**
94
+ * Cambia la URL base utilizada por SFW y vacía la caché anterior.
95
+ * @param {string} url
96
+ */
97
+ static setBaseURL(url) {
98
+ baseURL = validateBaseURL(url);
99
+ this.clearCache();
100
+ }
101
+
102
+ /**
103
+ * Configura la duración de la caché. Un valor de cero la desactiva.
104
+ * @param {number} milliseconds
105
+ */
106
+ static setCacheTTL(milliseconds) {
107
+ if (!Number.isFinite(milliseconds) || milliseconds < 0) {
108
+ throw new NekoError('InvalidCacheTTL', 'El tiempo de caché debe ser un número mayor o igual a cero');
109
+ }
110
+ cacheTTL = Math.floor(milliseconds);
111
+ this.clearCache();
112
+ }
113
+ }
114
+
115
+ /**
116
+ * @template {SfwCategories} T
117
+ * @param {T} category
118
+ * @param {GifMap[T]} gif
119
+ * @param {string} key
120
+ * @returns {Promise<GifData[]>}
121
+ */
122
+ async function getGifData(category, gif, key) {
123
+ const token = getToken();
124
+ if (!token) throw new NekoError('NoToken', 'Debes establecer un token antes de solicitar un GIF');
125
+ const currentTokenVersion = getTokenVersion();
126
+
127
+ const cached = gifCache.get(key);
128
+ if (
129
+ cached
130
+ && cached.tokenVersion === currentTokenVersion
131
+ && cached.expiresAt > Date.now()
132
+ ) return cached.items;
133
+ if (cached) gifCache.delete(key);
134
+
135
+ const pending = gifRequests.get(key);
136
+ if (pending?.tokenVersion === currentTokenVersion) return pending.promise;
137
+
138
+ const requestGlobalVersion = globalCacheVersion;
139
+ const requestKeyVersion = cacheVersions.get(key) ?? 0;
140
+ const request = requestGifList(category, gif, token)
141
+ .then(items => {
142
+ if (
143
+ cacheTTL > 0
144
+ && requestGlobalVersion === globalCacheVersion
145
+ && requestKeyVersion === (cacheVersions.get(key) ?? 0)
146
+ && currentTokenVersion === getTokenVersion()
147
+ ) {
148
+ storeCacheEntry(key, items, currentTokenVersion);
149
+ }
150
+ return items;
151
+ })
152
+ .finally(() => {
153
+ if (gifRequests.get(key)?.promise === request) gifRequests.delete(key);
154
+ });
155
+
156
+ gifRequests.set(key, { promise: request, tokenVersion: currentTokenVersion });
157
+ return request;
158
+ }
159
+
160
+ /**
161
+ * @template {SfwCategories} T
162
+ * @param {T} category
163
+ * @param {GifMap[T]} gif
164
+ * @param {string} token
165
+ * @returns {Promise<GifData[]>}
166
+ */
167
+ async function requestGifList(category, gif, token) {
168
+ const client = new APIClient(baseURL, { token });
169
+ const endpoint = `/${category}/${encodeURIComponent(String(gif))}`;
170
+ /** @type {GifListResponse} */
171
+ const response = await client.get(endpoint);
172
+
173
+ if (!Array.isArray(response?.data) || response.data.length === 0) {
174
+ throw new NekoError('EmptyResponse', `La API no devolvió GIF para ${category}/${gif}`);
175
+ }
176
+
177
+ const items = response.data.map((item, index) => normalizeGif(item, category, gif, index));
178
+ return removeDuplicateURLs(items);
179
+ }
180
+
181
+ const UNKNOWN_ANIME = 'Desconocido';
182
+ // Valores que la base de datos usa para "sin dato". Se tratan igual que un
183
+ // campo vacío para no mostrar, por ejemplo, "Unknown" en un embed en español.
184
+ const PLACEHOLDER_ANIME = new Set(['unknown', 'desconocido']);
185
+
186
+ /**
187
+ * @param {unknown} item
188
+ * @param {string} category
189
+ * @param {string} gif
190
+ * @param {number} index
191
+ * @returns {GifData}
192
+ */
193
+ function normalizeGif(item, category, gif, index) {
194
+ if (!item || typeof item !== 'object' || typeof item.url !== 'string' || !item.url.trim()) {
195
+ throw new NekoError('InvalidResponse', `El GIF ${index} de ${category}/${gif} no contiene una URL válida`);
196
+ }
197
+
198
+ const anime = typeof item.anime === 'string' ? item.anime.trim() : '';
199
+ return Object.freeze({
200
+ url: item.url.trim(),
201
+ anime: anime && !PLACEHOLDER_ANIME.has(anime.toLowerCase()) ? anime : UNKNOWN_ANIME
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Conserva la primera aparición de cada URL. Una URL repetida pesa doble en el
207
+ * sorteo y puede salir dos veces seguidas, porque la selección evita repetir
208
+ * posiciones, no URLs.
209
+ * @param {GifData[]} items
210
+ * @returns {GifData[]}
211
+ */
212
+ function removeDuplicateURLs(items) {
213
+ const seen = new Set();
214
+ return items.filter(item => {
215
+ if (seen.has(item.url)) return false;
216
+ seen.add(item.url);
217
+ return true;
218
+ });
219
+ }
220
+
221
+ /** @param {GifData} item @returns {NekoGif} */
222
+ function createNekoGif(item) {
223
+ return new NekoGif({ code: 200, data: { ...item } });
224
+ }
225
+
226
+ /**
227
+ * Elige uniformemente entre todas las posiciones, excepto la selección
228
+ * anterior cuando existen al menos dos alternativas.
229
+ * @param {number} length
230
+ * @param {number|undefined} previousIndex
231
+ */
232
+ function getRandomIndex(length, previousIndex) {
233
+ if (length === 1) return 0;
234
+ if (previousIndex === undefined || previousIndex < 0 || previousIndex >= length) {
235
+ return Math.floor(Math.random() * length);
236
+ }
237
+
238
+ const index = Math.floor(Math.random() * (length - 1));
239
+ return index >= previousIndex ? index + 1 : index;
240
+ }
241
+
242
+ /**
243
+ * @template {SfwCategories} T
244
+ * @param {T} category
245
+ * @param {GifMap[T]} gif
246
+ */
247
+ function getCacheKey(category, gif) {
248
+ validateCategory(category);
249
+ if (typeof gif !== 'string' || !gif.trim()) {
250
+ throw new NekoError('NoInput', 'el parámetro <gif> es requerido');
251
+ }
252
+ return `${category}:${gif}`;
253
+ }
254
+
255
+ /** @param {unknown} category */
256
+ function validateCategory(category) {
257
+ if (category === undefined || category === null || category === '') {
258
+ throw new NekoError('NoInput', 'el parámetro <category> es requerido');
259
+ }
260
+ if (category !== 'action' && category !== 'reaction') {
261
+ throw new NekoError('InvalidInput', 'la categoría que ingresas es inválida');
262
+ }
263
+ }
264
+
265
+ /** @param {string} url */
266
+ function validateBaseURL(url) {
267
+ if (typeof url !== 'string' || !url.trim()) {
268
+ throw new NekoError('InvalidBaseURL', 'La URL base debe ser una cadena HTTPS válida');
269
+ }
270
+
271
+ let parsed;
272
+ try {
273
+ parsed = new URL(url);
274
+ } catch {
275
+ throw new NekoError('InvalidBaseURL', 'La URL base debe ser una cadena HTTPS válida');
276
+ }
277
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
278
+ throw new NekoError('InvalidBaseURL', 'La URL base debe utilizar HTTPS y no incluir credenciales');
279
+ }
280
+ return url.trim().replace(/\/$/, '');
281
+ }
282
+
283
+ /** @param {string} key @param {GifData[]} items @param {number} tokenVersion */
284
+ function storeCacheEntry(key, items, tokenVersion) {
285
+ if (!gifCache.has(key) && gifCache.size >= MAX_CACHE_ENTRIES) {
286
+ const oldest = gifCache.keys().next().value;
287
+ if (oldest) {
288
+ gifCache.delete(oldest);
289
+ lastSelections.delete(oldest);
290
+ }
291
+ }
292
+ gifCache.set(key, { items, expiresAt: Date.now() + cacheTTL, tokenVersion });
293
+ }
294
+
295
+ /** @param {string} key */
296
+ function clearCacheKey(key) {
297
+ gifCache.delete(key);
298
+ gifRequests.delete(key);
299
+ lastSelections.delete(key);
300
+ cacheVersions.set(key, (cacheVersions.get(key) ?? 0) + 1);
301
+ }
302
+
303
+ /** @param {string} prefix */
304
+ function collectKnownKeys(prefix) {
305
+ return new Set([
306
+ ...[...gifCache.keys()].filter(key => key.startsWith(prefix)),
307
+ ...[...gifRequests.keys()].filter(key => key.startsWith(prefix)),
308
+ ...[...lastSelections.keys()].filter(key => key.startsWith(prefix)),
309
+ ...[...cacheVersions.keys()].filter(key => key.startsWith(prefix))
310
+ ]);
311
+ }
312
+
313
+ module.exports = SFW;
@@ -1,62 +1,62 @@
1
- 'use strict';
2
-
3
- /**
4
- * @typedef {import('../../typings').ApiResponse} ApiResponse
1
+ 'use strict';
2
+
3
+ /**
4
+ * @typedef {import('../../typings').ApiResponse} ApiResponse
5
5
  * @typedef {{url?: string, status?: number}} ResponseLike
6
- */
7
-
8
- /**
9
- * Clase APIError
10
- * Errores personalizados respecto a la api rest
11
- * @extends Error
12
- */
13
- class APIError extends Error {
14
- /**
15
- * Crea la instancia del error
16
- * @param {string} endpoint la ruta a la que se realizo la peticion
6
+ */
7
+
8
+ /**
9
+ * Clase APIError
10
+ * Errores personalizados respecto a la api rest
11
+ * @extends Error
12
+ */
13
+ class APIError extends Error {
14
+ /**
15
+ * Crea la instancia del error
16
+ * @param {string} endpoint la ruta a la que se realizo la peticion
17
17
  * @param {ResponseLike} response respuesta HTTP o representación de un error de red
18
- * @param {ApiResponse} data respuesta de la api
19
- */
18
+ * @param {ApiResponse} data respuesta de la api
19
+ */
20
20
  constructor(endpoint, response, data) {
21
21
  super(APIError.determineMessage(data));
22
-
23
- this.name = `NekoREST Error [${endpoint}]`
24
- /**
25
- * @type {string}
26
- */
27
- this.url = response?.url ?? 'unknown';
28
-
29
- /**
30
- * @type {number}
31
- */
22
+
23
+ this.name = `NekoREST Error [${endpoint}]`
24
+ /**
25
+ * @type {string}
26
+ */
27
+ this.url = response?.url ?? 'unknown';
28
+
29
+ /**
30
+ * @type {number}
31
+ */
32
32
  this.statusCode = Number.parseInt(response?.status ?? 500, 10);
33
-
34
- /**
35
- * @type {ApiResponse}
36
- */
37
- this.data = data
38
-
39
- }
40
-
41
- /**
42
- * Determina el mensaje del error basado en el codigo de la api
43
- * @param {ApiResponse} response respuesta de la api
44
- * @returns {string} mensaje de error
45
- */
33
+
34
+ /**
35
+ * @type {ApiResponse}
36
+ */
37
+ this.data = data
38
+
39
+ }
40
+
41
+ /**
42
+ * Determina el mensaje del error basado en el codigo de la api
43
+ * @param {ApiResponse} response respuesta de la api
44
+ * @returns {string} mensaje de error
45
+ */
46
46
  static determineMessage(response = {}) {
47
- if(response.code === 403) {
48
- return 'TOKEN ERROR: el token que haz proporcionado es invalido'
49
- } else
50
- if(response.code === 401) {
51
- return 'TOKEN ERROR: no se proporciono un token'
52
- } else {
53
- return response.message ?? 'descripcion no dada'
54
- }
47
+ if(response.code === 403) {
48
+ return 'TOKEN ERROR: el token que haz proporcionado es invalido'
49
+ } else
50
+ if(response.code === 401) {
51
+ return 'TOKEN ERROR: no se proporciono un token'
52
+ } else {
53
+ return response.message ?? 'descripcion no dada'
54
+ }
55
55
  }
56
56
 
57
57
  static fromNetworkError(endpoint, url, message) {
58
58
  return new APIError(endpoint, { url, status: 503 }, { code: 503, message });
59
59
  }
60
60
  }
61
-
61
+
62
62
  module.exports = APIError
@@ -1,26 +1,26 @@
1
- 'use strict';
2
-
3
- /**
4
- * Clase de erorres para la API
5
- * @extends Error
6
- */
7
- class NekoError extends Error {
8
- /**
9
- * Crea una instancia para errores del paquete
10
- * @param {string} name Corresponde al nombre del error
11
- * @param {string} message Mensaje de error
12
- */
1
+ 'use strict';
2
+
3
+ /**
4
+ * Clase de erorres para la API
5
+ * @extends Error
6
+ */
7
+ class NekoError extends Error {
8
+ /**
9
+ * Crea una instancia para errores del paquete
10
+ * @param {string} name Corresponde al nombre del error
11
+ * @param {string} message Mensaje de error
12
+ */
13
13
  constructor(name, message) {
14
14
  super(message);
15
-
16
- this.name = `NeeKuro Error [${name}]`
17
-
18
- /**
19
- * @type {string}
20
- */
15
+
16
+ this.name = `NeeKuro Error [${name}]`
17
+
18
+ /**
19
+ * @type {string}
20
+ */
21
21
  this.message = message
22
22
  Error.captureStackTrace?.(this, NekoError);
23
- }
24
- }
25
-
23
+ }
24
+ }
25
+
26
26
  module.exports = NekoError
@@ -1,53 +1,53 @@
1
- 'use strict';
2
-
3
- /**
4
- * @typedef {import("../../typings").GifResponse} GifResponse
5
- */
6
-
7
- /**
8
- * Clase NekoGif
9
- * crea un objeto Gif a partir de la respuesta del servidor
10
- */
11
- class NekoGif {
12
-
13
- /**
14
- * Recibe la respuesta de la api
15
- * @param {GifResponse} response respuesta de la api
16
- */
1
+ 'use strict';
2
+
3
+ /**
4
+ * @typedef {import("../../typings").GifResponse} GifResponse
5
+ */
6
+
7
+ /**
8
+ * Clase NekoGif
9
+ * crea un objeto Gif a partir de la respuesta del servidor
10
+ */
11
+ class NekoGif {
12
+
13
+ /**
14
+ * Recibe la respuesta de la api
15
+ * @param {GifResponse} response respuesta de la api
16
+ */
17
17
  constructor(response) {
18
18
  if (!response || typeof response !== 'object' || !response.data) {
19
19
  throw new TypeError('La respuesta no contiene datos de imagen');
20
20
  }
21
-
22
- /**
23
- * URL del gif
24
- * @type {string|null}
25
- */
21
+
22
+ /**
23
+ * URL del gif
24
+ * @type {string|null}
25
+ */
26
26
  this.url = typeof response.data.url === 'string' ? response.data.url : null
27
-
28
- /**
29
- * Anime del gif
30
- * @type {string|null}
31
- */
27
+
28
+ /**
29
+ * Anime del gif
30
+ * @type {string|null}
31
+ */
32
32
  this.anime = typeof response.data.anime === 'string' ? response.data.anime : null
33
- }
34
-
35
- /**
36
- * Obten la url del Gif
37
- * @returns {string|null} URL
38
- */
39
- getUrl() {
40
- return this.url;
41
- }
42
-
43
- /**
44
- * Obten el anime del Gif
45
- * @returns {string|null} Anime
46
- */
47
- getAnime() {
48
- return this.anime;
49
- }
50
-
51
- }
52
-
33
+ }
34
+
35
+ /**
36
+ * Obten la url del Gif
37
+ * @returns {string|null} URL
38
+ */
39
+ getUrl() {
40
+ return this.url;
41
+ }
42
+
43
+ /**
44
+ * Obten el anime del Gif
45
+ * @returns {string|null} Anime
46
+ */
47
+ getAnime() {
48
+ return this.anime;
49
+ }
50
+
51
+ }
52
+
53
53
  module.exports = NekoGif