neekuro 2.3.1 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,76 +1,84 @@
1
- # Changelog
2
-
3
- ## [2.3.1] - 07-10-2026
4
-
5
- ### Fixed
6
- - La URL por defecto del API pasa a `https://maplebot.fyi/api/sfw`. El dominio anterior dejo de resolver, asi que las versiones previas no podian obtener ninguna imagen.
7
-
8
- ### Compatibility
9
- - No cambia ninguna firma. Quien ya fijaba su endpoint con `SFW.setBaseURL()` no se ve afectado.
10
-
11
- ## [2.3.0] - 15-09-2026
12
-
13
- ### Added
14
- - Nuevas subcategorías de `action`: `handhold`, `highfive`, `read` y `wave`.
15
- - Nuevas subcategorías de `reaction`: `disgust`, `facepalm`, `happy`, `love`, `nervous`, `shrug` y `surprised`.
16
-
17
- ### Fixed
18
- - El timeout de 10 segundos de las imágenes remotas ahora también cubre la lectura del cuerpo, no solo las cabeceras.
19
- - Las respuestas descartadas (redirecciones, errores HTTP o formatos inválidos) liberan su conexión.
20
- - El User-Agent de descarga de imágenes refleja la versión real del paquete.
21
- - Declaraciones TypeScript: `APIError.determineMessage` es estático, se añade `APIError.fromNetworkError` y se corrige la resolución por defecto documentada (1140 × 520) y el ejemplo de `User.token()`.
22
- - `SFW` muestra `Desconocido` cuando el anime llega como `Unknown`, no solo cuando viene vacío.
23
- - `SFW` descarta las URL repetidas de una subcategoría: pesaban doble en el sorteo y podían salir dos veces seguidas.
24
-
25
- ### Compatibility
26
- - Solo se añaden subcategorías; las existentes no cambian.
27
-
28
- ## [2.2.0] - 28-08-2026
29
-
30
- ### Added
31
- - `SFW.getGifs()` permite obtener todas las imágenes de una subcategoría.
32
- - `SFW.clearCache()` permite invalidar toda la caché, una categoría o una subcategoría.
33
- - `SFW.setBaseURL()` permite configurar un endpoint HTTPS alternativo.
34
- - `SFW.setCacheTTL()` permite configurar o desactivar el tiempo de caché.
35
-
36
- ### Changed
37
- - `SFW.getGif()` conserva listas en memoria durante cinco minutos y selecciona aleatoriamente en cada llamada.
38
- - Las solicitudes concurrentes para la misma subcategoría se agrupan en una sola petición HTTP.
39
- - Cuando existen varias imágenes se evita repetir inmediatamente la selección anterior.
40
- - Cambiar el token invalida automáticamente las listas obtenidas con el token previo.
41
- - El cliente REST admite respuestas tipadas distintas a un único GIF.
42
-
43
- ### Compatibility
44
- - `SFW.getGif(category, gif)` mantiene la misma firma y continúa devolviendo `NekoGif`.
45
- - La caché solamente cambia la frecuencia de consulta; no fija una imagen durante el TTL.
46
-
47
- ## [2.1.0] - 28-08-2026
48
-
49
- ### Changed
50
- - Se corrigieron imports sensibles a mayúsculas para funcionar en Linux.
51
- - Se eliminó la dependencia circular del paquete hacia sí mismo y `node-fetch`.
52
- - `Welcome` valida resolución, coordenadas, radios, fuentes e imágenes con límites seguros.
53
- - Avatar, título y descripción utilizan una columna centrada automática con modo manual opcional.
54
- - Las imágenes remotas requieren HTTPS, tienen timeout, límite de 10 MB y bloqueo de redes privadas.
55
- - El cliente REST usa `fetch` nativo, timeout y conserva el error concreto de red.
56
- - Se alinearon los exports, la implementación y las declaraciones TypeScript.
57
-
58
- ### Compatibility
59
- - Se conserva la API encadenable de `Welcome`.
60
- - `Error` continúa disponible como alias de `NekoError`.
61
- - Las URLs HTTP de imágenes dejan de aceptarse; deben utilizar HTTPS.
62
-
63
- ## [2.0.3] - 07-04-2025
64
-
65
- ### Fixed
66
- - **Error de Importaciones**
67
- - Corregido el error de importaciones require('...')`
68
- - Causaba que no se pudiera usar el paquete
69
- - Se uso el mismo nombre para todas las importaciones respecto al archivo
70
- - Afectaba a todos los usuarios que hacian uso de la api
71
-
72
- **Impacto**:
73
- - La actualizacion prevee que resuelva el error
74
- - No requiere cambios en implementaciones existentes
75
-
76
- Commit: (1e7380a)
1
+ # Changelog
2
+
3
+ ## [2.3.2] - 25-10-2026
4
+
5
+ ### Changed
6
+ - La URL por defecto del API pasa a `https://api.maplebot.fyi/api/sfw`. El API se separa de la web y deja de responder en `maplebot.fyi/api/sfw`, asi que las versiones 2.3.1 y anteriores no pueden obtener imagenes sin fijar el endpoint.
7
+
8
+ ### Compatibility
9
+ - No cambia ninguna firma. Quien no pueda actualizar puede seguir con su version llamando a `SFW.setBaseURL('https://api.maplebot.fyi/api/sfw')`.
10
+
11
+ ## [2.3.1] - 07-10-2026
12
+
13
+ ### Fixed
14
+ - La URL por defecto del API pasa a `https://maplebot.fyi/api/sfw`. El dominio anterior dejo de resolver, asi que las versiones previas no podian obtener ninguna imagen.
15
+
16
+ ### Compatibility
17
+ - No cambia ninguna firma. Quien ya fijaba su endpoint con `SFW.setBaseURL()` no se ve afectado.
18
+
19
+ ## [2.3.0] - 15-09-2026
20
+
21
+ ### Added
22
+ - Nuevas subcategorías de `action`: `handhold`, `highfive`, `read` y `wave`.
23
+ - Nuevas subcategorías de `reaction`: `disgust`, `facepalm`, `happy`, `love`, `nervous`, `shrug` y `surprised`.
24
+
25
+ ### Fixed
26
+ - El timeout de 10 segundos de las imágenes remotas ahora también cubre la lectura del cuerpo, no solo las cabeceras.
27
+ - Las respuestas descartadas (redirecciones, errores HTTP o formatos inválidos) liberan su conexión.
28
+ - El User-Agent de descarga de imágenes refleja la versión real del paquete.
29
+ - Declaraciones TypeScript: `APIError.determineMessage` es estático, se añade `APIError.fromNetworkError` y se corrige la resolución por defecto documentada (1140 × 520) y el ejemplo de `User.token()`.
30
+ - `SFW` muestra `Desconocido` cuando el anime llega como `Unknown`, no solo cuando viene vacío.
31
+ - `SFW` descarta las URL repetidas de una subcategoría: pesaban doble en el sorteo y podían salir dos veces seguidas.
32
+
33
+ ### Compatibility
34
+ - Solo se añaden subcategorías; las existentes no cambian.
35
+
36
+ ## [2.2.0] - 28-08-2026
37
+
38
+ ### Added
39
+ - `SFW.getGifs()` permite obtener todas las imágenes de una subcategoría.
40
+ - `SFW.clearCache()` permite invalidar toda la caché, una categoría o una subcategoría.
41
+ - `SFW.setBaseURL()` permite configurar un endpoint HTTPS alternativo.
42
+ - `SFW.setCacheTTL()` permite configurar o desactivar el tiempo de caché.
43
+
44
+ ### Changed
45
+ - `SFW.getGif()` conserva listas en memoria durante cinco minutos y selecciona aleatoriamente en cada llamada.
46
+ - Las solicitudes concurrentes para la misma subcategoría se agrupan en una sola petición HTTP.
47
+ - Cuando existen varias imágenes se evita repetir inmediatamente la selección anterior.
48
+ - Cambiar el token invalida automáticamente las listas obtenidas con el token previo.
49
+ - El cliente REST admite respuestas tipadas distintas a un único GIF.
50
+
51
+ ### Compatibility
52
+ - `SFW.getGif(category, gif)` mantiene la misma firma y continúa devolviendo `NekoGif`.
53
+ - La caché solamente cambia la frecuencia de consulta; no fija una imagen durante el TTL.
54
+
55
+ ## [2.1.0] - 28-08-2026
56
+
57
+ ### Changed
58
+ - Se corrigieron imports sensibles a mayúsculas para funcionar en Linux.
59
+ - Se eliminó la dependencia circular del paquete hacia sí mismo y `node-fetch`.
60
+ - `Welcome` valida resolución, coordenadas, radios, fuentes e imágenes con límites seguros.
61
+ - Avatar, título y descripción utilizan una columna centrada automática con modo manual opcional.
62
+ - Las imágenes remotas requieren HTTPS, tienen timeout, límite de 10 MB y bloqueo de redes privadas.
63
+ - El cliente REST usa `fetch` nativo, timeout y conserva el error concreto de red.
64
+ - Se alinearon los exports, la implementación y las declaraciones TypeScript.
65
+
66
+ ### Compatibility
67
+ - Se conserva la API encadenable de `Welcome`.
68
+ - `Error` continúa disponible como alias de `NekoError`.
69
+ - Las URLs HTTP de imágenes dejan de aceptarse; deben utilizar HTTPS.
70
+
71
+ ## [2.0.3] - 07-04-2025
72
+
73
+ ### Fixed
74
+ - **Error de Importaciones**
75
+ - Corregido el error de importaciones require('...')`
76
+ - Causaba que no se pudiera usar el paquete
77
+ - Se uso el mismo nombre para todas las importaciones respecto al archivo
78
+ - Afectaba a todos los usuarios que hacian uso de la api
79
+
80
+ **Impacto**:
81
+ - La actualizacion prevee que resuelva el error
82
+ - No requiere cambios en implementaciones existentes
83
+
84
+ Commit: (1e7380a)
package/README.md CHANGED
@@ -166,12 +166,11 @@ declaraciones TypeScript. Ninguna de estas pruebas consulta la API remota.
166
166
 
167
167
  ## Contenido
168
168
 
169
- Lo siguente es lo que contiene (o contendra) el paquete
169
+ Lo siguente es lo que contiene el paquete
170
170
 
171
171
  | Categoria | Descripción | |
172
172
  |-|-|-|
173
173
  | Sfw | Contenido de roleplay no nsfw | en desarrollo
174
- | Nsfw | Contenido +18 general y roleplay | no comenzada
175
174
 
176
175
  ## Finalmente....
177
176
 
package/package.json CHANGED
@@ -1,69 +1,68 @@
1
- {
2
- "$schema": "https://json.schemastore.org/package.json",
3
- "name": "neekuro",
4
- "version": "2.3.1",
5
- "description": "Una biblioteca de imágenes con temática de anime para bots de Discord.",
6
- "main": "sources/main/index.js",
7
- "types": "sources/typings/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "import": {
11
- "types": "./sources/typings/index.d.ts",
12
- "default": "./sources/main/index.js"
13
- },
14
- "require": {
15
- "types": "./sources/typings/index.d.ts",
16
- "default": "./sources/main/index.js"
17
- }
18
- }
19
- },
20
- "directories": {
21
- "test": "test"
22
- },
23
- "files": [
24
- "sources",
25
- "README.md",
26
- "CHANGELOG.md"
27
- ],
28
- "contributors": [
29
- "cacao0027 <kmzkuro@gmail.com>"
30
- ],
31
- "author": "cacao0027 <kmzkuro@gmail.com>",
32
- "keywords": [
33
- "anime",
34
- "acción",
35
- "gifs",
36
- "imagenes",
37
- "nsfw",
38
- "sfw",
39
- "discord",
40
- "action",
41
- "images"
42
- ],
43
- "license": "ISC",
44
- "packageManager": "pnpm@11.23.0",
45
- "scripts": {
46
- "test": "node --test test/*.test.js",
47
- "check": "node --check sources/main/index.js && node --test test/*.test.js && tsc -p test/tsconfig.json",
48
- "pack:check": "npm pack --dry-run"
49
- },
50
- "engines": {
51
- "node": ">=20.12.2"
52
- },
53
- "dependencies": {
54
- "canvas": "^3.1.0",
55
- "hex-color-regex": "^1.1.0"
56
- },
57
- "devDependencies": {
58
- "@types/node": "^24.0.0",
59
- "typescript": "^5.9.0"
60
- },
61
- "repository": {
62
- "type": "git",
63
- "url": "git+https://github.com/CacaoNk0027/neekuro_package.git"
64
- },
65
- "bugs": {
66
- "url": "https://github.com/CacaoNk0027/neekuro_package/issues"
67
- },
68
- "homepage": "https://github.com/CacaoNk0027/neekuro_package#readme"
69
- }
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "neekuro",
4
+ "version": "2.3.2",
5
+ "description": "Una biblioteca de imágenes con temática de anime para bots de Discord.",
6
+ "main": "sources/main/index.js",
7
+ "types": "sources/typings/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": {
11
+ "types": "./sources/typings/index.d.ts",
12
+ "default": "./sources/main/index.js"
13
+ },
14
+ "require": {
15
+ "types": "./sources/typings/index.d.ts",
16
+ "default": "./sources/main/index.js"
17
+ }
18
+ }
19
+ },
20
+ "directories": {
21
+ "test": "test"
22
+ },
23
+ "files": [
24
+ "sources",
25
+ "README.md",
26
+ "CHANGELOG.md"
27
+ ],
28
+ "contributors": [
29
+ "cacao0027 <kmzkuro@gmail.com>"
30
+ ],
31
+ "author": "cacao0027 <kmzkuro@gmail.com>",
32
+ "keywords": [
33
+ "anime",
34
+ "acción",
35
+ "gifs",
36
+ "imagenes",
37
+ "sfw",
38
+ "discord",
39
+ "action",
40
+ "images"
41
+ ],
42
+ "license": "ISC",
43
+ "packageManager": "pnpm@11.23.0",
44
+ "scripts": {
45
+ "test": "node --test test/*.test.js",
46
+ "check": "node --check sources/main/index.js && node --test test/*.test.js && tsc -p test/tsconfig.json",
47
+ "pack:check": "npm pack --dry-run"
48
+ },
49
+ "engines": {
50
+ "node": ">=20.12.2"
51
+ },
52
+ "dependencies": {
53
+ "canvas": "^3.1.0",
54
+ "hex-color-regex": "^1.1.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^24.0.0",
58
+ "typescript": "^5.9.0"
59
+ },
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/CacaoNk0027/neekuro_package.git"
63
+ },
64
+ "bugs": {
65
+ "url": "https://github.com/CacaoNk0027/neekuro_package/issues"
66
+ },
67
+ "homepage": "https://github.com/CacaoNk0027/neekuro_package#readme"
68
+ }
@@ -1,313 +1,313 @@
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://maplebot.fyi/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
+ '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://api.maplebot.fyi/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;