neekuro 2.0.4 → 2.2.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/CHANGELOG.md +51 -0
- package/README.md +74 -6
- package/package.json +17 -11
- package/sources/classes/SFW.js +292 -45
- package/sources/classes/errors/{APIerror.js → APIError.js} +14 -12
- package/sources/classes/errors/Error.js +5 -4
- package/sources/classes/package/NekoGif.js +7 -4
- package/sources/classes/package/{Welcome.js → welcome.js} +162 -96
- package/sources/classes/user/BaseUser.js +21 -7
- package/sources/classes/user/User.js +7 -7
- package/sources/main/index.js +6 -4
- package/sources/rest/APIClient.js +73 -31
- package/sources/typings/index.d.ts +53 -29
- package/sources/utils/imageLoader.js +146 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [2.2.0] - 28-08-2026
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `SFW.getGifs()` permite obtener todas las imágenes de una subcategoría.
|
|
7
|
+
- `SFW.clearCache()` permite invalidar toda la caché, una categoría o una subcategoría.
|
|
8
|
+
- `SFW.setBaseURL()` permite configurar un endpoint HTTPS alternativo.
|
|
9
|
+
- `SFW.setCacheTTL()` permite configurar o desactivar el tiempo de caché.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- `SFW.getGif()` conserva listas en memoria durante cinco minutos y selecciona aleatoriamente en cada llamada.
|
|
13
|
+
- Las solicitudes concurrentes para la misma subcategoría se agrupan en una sola petición HTTP.
|
|
14
|
+
- Cuando existen varias imágenes se evita repetir inmediatamente la selección anterior.
|
|
15
|
+
- Cambiar el token invalida automáticamente las listas obtenidas con el token previo.
|
|
16
|
+
- El cliente REST admite respuestas tipadas distintas a un único GIF.
|
|
17
|
+
|
|
18
|
+
### Compatibility
|
|
19
|
+
- `SFW.getGif(category, gif)` mantiene la misma firma y continúa devolviendo `NekoGif`.
|
|
20
|
+
- La caché solamente cambia la frecuencia de consulta; no fija una imagen durante el TTL.
|
|
21
|
+
|
|
22
|
+
## [2.1.0] - 28-08-2026
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- Se corrigieron imports sensibles a mayúsculas para funcionar en Linux.
|
|
26
|
+
- Se eliminó la dependencia circular del paquete hacia sí mismo y `node-fetch`.
|
|
27
|
+
- `Welcome` valida resolución, coordenadas, radios, fuentes e imágenes con límites seguros.
|
|
28
|
+
- Avatar, título y descripción utilizan una columna centrada automática con modo manual opcional.
|
|
29
|
+
- Las imágenes remotas requieren HTTPS, tienen timeout, límite de 10 MB y bloqueo de redes privadas.
|
|
30
|
+
- El cliente REST usa `fetch` nativo, timeout y conserva el error concreto de red.
|
|
31
|
+
- Se alinearon los exports, la implementación y las declaraciones TypeScript.
|
|
32
|
+
|
|
33
|
+
### Compatibility
|
|
34
|
+
- Se conserva la API encadenable de `Welcome`.
|
|
35
|
+
- `Error` continúa disponible como alias de `NekoError`.
|
|
36
|
+
- Las URLs HTTP de imágenes dejan de aceptarse; deben utilizar HTTPS.
|
|
37
|
+
|
|
38
|
+
## [2.0.3] - 07-04-2025
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
- **Error de Importaciones**
|
|
42
|
+
- Corregido el error de importaciones require('...')`
|
|
43
|
+
- Causaba que no se pudiera usar el paquete
|
|
44
|
+
- Se uso el mismo nombre para todas las importaciones respecto al archivo
|
|
45
|
+
- Afectaba a todos los usuarios que hacian uso de la api
|
|
46
|
+
|
|
47
|
+
**Impacto**:
|
|
48
|
+
- La actualizacion prevee que resuelva el error
|
|
49
|
+
- No requiere cambios en implementaciones existentes
|
|
50
|
+
|
|
51
|
+
Commit: (1e7380a)
|
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ const neekuro = require('neekuro');
|
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
58
|
// es necesario escribir todo siempre dentro de una funcion asincrona
|
|
59
|
-
async function ObtenerImagen() {
|
|
59
|
+
async function ObtenerImagen() {
|
|
60
60
|
// obtenemos un gif con el metodo getGif de SFW
|
|
61
61
|
const cuddle = await neekuro.SFW.getGif('action', 'cuddle');
|
|
62
62
|
|
|
@@ -65,11 +65,45 @@ async function ObtenerImagen() {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// ejecutamos la funcion
|
|
68
|
-
ObtenerImagen();
|
|
68
|
+
ObtenerImagen();
|
|
69
69
|
|
|
70
70
|
// nota: no te aguites por el gif y subcategoria, todo viene en documentado con typescript, solo tienes que completar los pasos :)
|
|
71
|
-
```
|
|
72
|
-
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`getGif()` selecciona una imagen nuevamente en cada llamada. Internamente, NeeKuro
|
|
74
|
+
conserva durante cinco minutos la lista de la subcategoría para reducir solicitudes
|
|
75
|
+
HTTP; no conserva un único GIF durante ese tiempo. Cuando existen varias imágenes,
|
|
76
|
+
evita mostrar la misma dos veces consecutivas.
|
|
77
|
+
|
|
78
|
+
También puedes obtener la lista completa:
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
const gifs = await neekuro.SFW.getGifs('action', 'cuddle');
|
|
82
|
+
console.log(gifs.map(gif => gif.getUrl()));
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Si actualizaste el catálogo y necesitas consultarlo inmediatamente, puedes limpiar
|
|
86
|
+
una subcategoría, una categoría completa o toda la caché:
|
|
87
|
+
|
|
88
|
+
```javascript
|
|
89
|
+
neekuro.SFW.clearCache('action', 'cuddle');
|
|
90
|
+
neekuro.SFW.clearCache('action');
|
|
91
|
+
neekuro.SFW.clearCache();
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
El TTL se expresa en milisegundos y un valor de cero desactiva la caché:
|
|
95
|
+
|
|
96
|
+
```javascript
|
|
97
|
+
neekuro.SFW.setCacheTTL(5 * 60 * 1000);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Para pruebas o instalaciones propias de la API puede configurarse otra URL HTTPS:
|
|
101
|
+
|
|
102
|
+
```javascript
|
|
103
|
+
neekuro.SFW.setBaseURL('https://api.example.com/api/sfw');
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
#### Ejemplo de imagenes de bienvenida
|
|
73
107
|
Las imagenes de bienvenida son otra de las alternativas que otorga NeeKuro, puedes hacer una imagen de la siguiente manera:
|
|
74
108
|
```javascript
|
|
75
109
|
// obtenemos directamente la clase del paquete
|
|
@@ -83,13 +117,47 @@ const welcome = new Welcome()
|
|
|
83
117
|
.setDescription('Al servidor de Discord');
|
|
84
118
|
|
|
85
119
|
// Genera y guarda la imagen
|
|
86
|
-
welcome.build()
|
|
120
|
+
welcome.build()
|
|
87
121
|
.then(image => fs.writeFileSync('bienvenida.png', image))
|
|
88
122
|
.then(() => console.log('Imagen creada correctamente!'))
|
|
89
123
|
.catch(err => console.error('Error:', err.message));
|
|
90
124
|
|
|
91
125
|
// nota: como antes, todo viene bien documentado en el paquete, solo sigue los pasos y explora por ti ;3
|
|
92
|
-
```
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Las imágenes remotas deben usar HTTPS. El paquete limita cada descarga a 10 MB,
|
|
129
|
+
aplica un tiempo máximo de 10 segundos, comprueba el formato y bloquea direcciones
|
|
130
|
+
locales o privadas. También puedes proporcionar un `Buffer` JPEG, PNG, GIF o WebP.
|
|
131
|
+
|
|
132
|
+
La resolución admite valores enteros entre 128 y 4096 píxeles. Para regresar al
|
|
133
|
+
tamaño original de 1140 × 520 puedes usar:
|
|
134
|
+
|
|
135
|
+
```javascript
|
|
136
|
+
welcome.setResolution('default', 'default');
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Por defecto, el avatar, el título y la descripción forman una columna centrada
|
|
140
|
+
horizontal y verticalmente. El tamaño del avatar y de los textos se adapta cuando
|
|
141
|
+
la resolución es pequeña. Si necesitas coordenadas personalizadas puedes activar
|
|
142
|
+
el modo manual directamente o proporcionar `x`/`y` en cualquiera de los elementos:
|
|
143
|
+
|
|
144
|
+
```javascript
|
|
145
|
+
welcome.setLayout('manual');
|
|
146
|
+
|
|
147
|
+
// Para regresar a la distribución automática:
|
|
148
|
+
welcome.setLayout('center');
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Desarrollo
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
pnpm install
|
|
155
|
+
pnpm run check
|
|
156
|
+
pnpm run pack:check
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`pnpm run check` ejecuta las pruebas funcionales locales y comprueba las
|
|
160
|
+
declaraciones TypeScript. Ninguna de estas pruebas consulta la API remota.
|
|
93
161
|
<hr>
|
|
94
162
|
<center>
|
|
95
163
|
<p>Resultado esperado</p>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "neekuro",
|
|
4
|
-
"version": "2.0
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"description": "Una biblioteca de imágenes con temática de anime para bots de Discord.",
|
|
6
6
|
"main": "sources/main/index.js",
|
|
7
7
|
"types": "sources/typings/index.d.ts",
|
|
@@ -20,14 +20,15 @@
|
|
|
20
20
|
"directories": {
|
|
21
21
|
"test": "test"
|
|
22
22
|
},
|
|
23
|
-
"files": [
|
|
24
|
-
"sources",
|
|
25
|
-
"README.md"
|
|
26
|
-
|
|
23
|
+
"files": [
|
|
24
|
+
"sources",
|
|
25
|
+
"README.md",
|
|
26
|
+
"CHANGELOG.md"
|
|
27
|
+
],
|
|
27
28
|
"contributors": [
|
|
28
|
-
"cacao0027 <
|
|
29
|
+
"cacao0027 <kmzkuro@gmail.com>"
|
|
29
30
|
],
|
|
30
|
-
"author": "cacao0027 <
|
|
31
|
+
"author": "cacao0027 <kmzkuro@gmail.com>",
|
|
31
32
|
"keywords": [
|
|
32
33
|
"anime",
|
|
33
34
|
"acción",
|
|
@@ -40,17 +41,22 @@
|
|
|
40
41
|
"images"
|
|
41
42
|
],
|
|
42
43
|
"license": "ISC",
|
|
44
|
+
"packageManager": "pnpm@11.23.0",
|
|
43
45
|
"scripts": {
|
|
44
|
-
"test": "
|
|
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"
|
|
45
49
|
},
|
|
46
50
|
"engines": {
|
|
47
51
|
"node": ">=20.12.2"
|
|
48
52
|
},
|
|
49
53
|
"dependencies": {
|
|
50
54
|
"canvas": "^3.1.0",
|
|
51
|
-
"hex-color-regex": "^1.1.0"
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
"hex-color-regex": "^1.1.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "^24.0.0",
|
|
59
|
+
"typescript": "^5.9.0"
|
|
54
60
|
},
|
|
55
61
|
"repository": {
|
|
56
62
|
"type": "git",
|
package/sources/classes/SFW.js
CHANGED
|
@@ -1,45 +1,292 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
return response.data.map((item, index) => normalizeGif(item, category, gif, index));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {unknown} item
|
|
182
|
+
* @param {string} category
|
|
183
|
+
* @param {string} gif
|
|
184
|
+
* @param {number} index
|
|
185
|
+
* @returns {GifData}
|
|
186
|
+
*/
|
|
187
|
+
function normalizeGif(item, category, gif, index) {
|
|
188
|
+
if (!item || typeof item !== 'object' || typeof item.url !== 'string' || !item.url.trim()) {
|
|
189
|
+
throw new NekoError('InvalidResponse', `El GIF ${index} de ${category}/${gif} no contiene una URL válida`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return Object.freeze({
|
|
193
|
+
url: item.url.trim(),
|
|
194
|
+
anime: typeof item.anime === 'string' && item.anime.trim()
|
|
195
|
+
? item.anime.trim()
|
|
196
|
+
: 'Desconocido'
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** @param {GifData} item @returns {NekoGif} */
|
|
201
|
+
function createNekoGif(item) {
|
|
202
|
+
return new NekoGif({ code: 200, data: { ...item } });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Elige uniformemente entre todas las posiciones, excepto la selección
|
|
207
|
+
* anterior cuando existen al menos dos alternativas.
|
|
208
|
+
* @param {number} length
|
|
209
|
+
* @param {number|undefined} previousIndex
|
|
210
|
+
*/
|
|
211
|
+
function getRandomIndex(length, previousIndex) {
|
|
212
|
+
if (length === 1) return 0;
|
|
213
|
+
if (previousIndex === undefined || previousIndex < 0 || previousIndex >= length) {
|
|
214
|
+
return Math.floor(Math.random() * length);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const index = Math.floor(Math.random() * (length - 1));
|
|
218
|
+
return index >= previousIndex ? index + 1 : index;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @template {SfwCategories} T
|
|
223
|
+
* @param {T} category
|
|
224
|
+
* @param {GifMap[T]} gif
|
|
225
|
+
*/
|
|
226
|
+
function getCacheKey(category, gif) {
|
|
227
|
+
validateCategory(category);
|
|
228
|
+
if (typeof gif !== 'string' || !gif.trim()) {
|
|
229
|
+
throw new NekoError('NoInput', 'el parámetro <gif> es requerido');
|
|
230
|
+
}
|
|
231
|
+
return `${category}:${gif}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** @param {unknown} category */
|
|
235
|
+
function validateCategory(category) {
|
|
236
|
+
if (category === undefined || category === null || category === '') {
|
|
237
|
+
throw new NekoError('NoInput', 'el parámetro <category> es requerido');
|
|
238
|
+
}
|
|
239
|
+
if (category !== 'action' && category !== 'reaction') {
|
|
240
|
+
throw new NekoError('InvalidInput', 'la categoría que ingresas es inválida');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @param {string} url */
|
|
245
|
+
function validateBaseURL(url) {
|
|
246
|
+
if (typeof url !== 'string' || !url.trim()) {
|
|
247
|
+
throw new NekoError('InvalidBaseURL', 'La URL base debe ser una cadena HTTPS válida');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let parsed;
|
|
251
|
+
try {
|
|
252
|
+
parsed = new URL(url);
|
|
253
|
+
} catch {
|
|
254
|
+
throw new NekoError('InvalidBaseURL', 'La URL base debe ser una cadena HTTPS válida');
|
|
255
|
+
}
|
|
256
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
|
|
257
|
+
throw new NekoError('InvalidBaseURL', 'La URL base debe utilizar HTTPS y no incluir credenciales');
|
|
258
|
+
}
|
|
259
|
+
return url.trim().replace(/\/$/, '');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** @param {string} key @param {GifData[]} items @param {number} tokenVersion */
|
|
263
|
+
function storeCacheEntry(key, items, tokenVersion) {
|
|
264
|
+
if (!gifCache.has(key) && gifCache.size >= MAX_CACHE_ENTRIES) {
|
|
265
|
+
const oldest = gifCache.keys().next().value;
|
|
266
|
+
if (oldest) {
|
|
267
|
+
gifCache.delete(oldest);
|
|
268
|
+
lastSelections.delete(oldest);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
gifCache.set(key, { items, expiresAt: Date.now() + cacheTTL, tokenVersion });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** @param {string} key */
|
|
275
|
+
function clearCacheKey(key) {
|
|
276
|
+
gifCache.delete(key);
|
|
277
|
+
gifRequests.delete(key);
|
|
278
|
+
lastSelections.delete(key);
|
|
279
|
+
cacheVersions.set(key, (cacheVersions.get(key) ?? 0) + 1);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** @param {string} prefix */
|
|
283
|
+
function collectKnownKeys(prefix) {
|
|
284
|
+
return new Set([
|
|
285
|
+
...[...gifCache.keys()].filter(key => key.startsWith(prefix)),
|
|
286
|
+
...[...gifRequests.keys()].filter(key => key.startsWith(prefix)),
|
|
287
|
+
...[...lastSelections.keys()].filter(key => key.startsWith(prefix)),
|
|
288
|
+
...[...cacheVersions.keys()].filter(key => key.startsWith(prefix))
|
|
289
|
+
]);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
module.exports = SFW;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @typedef {import('../../typings').ApiResponse} ApiResponse
|
|
5
|
-
* @typedef {
|
|
5
|
+
* @typedef {{url?: string, status?: number}} ResponseLike
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -14,15 +14,13 @@ class APIError extends Error {
|
|
|
14
14
|
/**
|
|
15
15
|
* Crea la instancia del error
|
|
16
16
|
* @param {string} endpoint la ruta a la que se realizo la peticion
|
|
17
|
-
* @param {
|
|
17
|
+
* @param {ResponseLike} response respuesta HTTP o representación de un error de red
|
|
18
18
|
* @param {ApiResponse} data respuesta de la api
|
|
19
19
|
*/
|
|
20
|
-
constructor(endpoint, response, data) {
|
|
21
|
-
super();
|
|
20
|
+
constructor(endpoint, response, data) {
|
|
21
|
+
super(APIError.determineMessage(data));
|
|
22
22
|
|
|
23
23
|
this.name = `NekoREST Error [${endpoint}]`
|
|
24
|
-
this.message = this.#determineMessage(data);
|
|
25
|
-
|
|
26
24
|
/**
|
|
27
25
|
* @type {string}
|
|
28
26
|
*/
|
|
@@ -31,7 +29,7 @@ class APIError extends Error {
|
|
|
31
29
|
/**
|
|
32
30
|
* @type {number}
|
|
33
31
|
*/
|
|
34
|
-
this.statusCode = parseInt(response?.status ?? 500);
|
|
32
|
+
this.statusCode = Number.parseInt(response?.status ?? 500, 10);
|
|
35
33
|
|
|
36
34
|
/**
|
|
37
35
|
* @type {ApiResponse}
|
|
@@ -45,7 +43,7 @@ class APIError extends Error {
|
|
|
45
43
|
* @param {ApiResponse} response respuesta de la api
|
|
46
44
|
* @returns {string} mensaje de error
|
|
47
45
|
*/
|
|
48
|
-
|
|
46
|
+
static determineMessage(response = {}) {
|
|
49
47
|
if(response.code === 403) {
|
|
50
48
|
return 'TOKEN ERROR: el token que haz proporcionado es invalido'
|
|
51
49
|
} else
|
|
@@ -54,7 +52,11 @@ class APIError extends Error {
|
|
|
54
52
|
} else {
|
|
55
53
|
return response.message ?? 'descripcion no dada'
|
|
56
54
|
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static fromNetworkError(endpoint, url, message) {
|
|
58
|
+
return new APIError(endpoint, { url, status: 503 }, { code: 503, message });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = APIError
|
|
@@ -10,16 +10,17 @@ class NekoError extends Error {
|
|
|
10
10
|
* @param {string} name Corresponde al nombre del error
|
|
11
11
|
* @param {string} message Mensaje de error
|
|
12
12
|
*/
|
|
13
|
-
constructor(name, message) {
|
|
14
|
-
super(message);
|
|
13
|
+
constructor(name, message) {
|
|
14
|
+
super(message);
|
|
15
15
|
|
|
16
16
|
this.name = `NeeKuro Error [${name}]`
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* @type {string}
|
|
20
20
|
*/
|
|
21
|
-
this.message = message
|
|
21
|
+
this.message = message
|
|
22
|
+
Error.captureStackTrace?.(this, NekoError);
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
module.exports = NekoError
|
|
26
|
+
module.exports = NekoError
|
|
@@ -14,19 +14,22 @@ class NekoGif {
|
|
|
14
14
|
* Recibe la respuesta de la api
|
|
15
15
|
* @param {GifResponse} response respuesta de la api
|
|
16
16
|
*/
|
|
17
|
-
constructor(response) {
|
|
17
|
+
constructor(response) {
|
|
18
|
+
if (!response || typeof response !== 'object' || !response.data) {
|
|
19
|
+
throw new TypeError('La respuesta no contiene datos de imagen');
|
|
20
|
+
}
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* URL del gif
|
|
21
24
|
* @type {string|null}
|
|
22
25
|
*/
|
|
23
|
-
this.url = response.data.url
|
|
26
|
+
this.url = typeof response.data.url === 'string' ? response.data.url : null
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* Anime del gif
|
|
27
30
|
* @type {string|null}
|
|
28
31
|
*/
|
|
29
|
-
this.anime = response.data.anime
|
|
32
|
+
this.anime = typeof response.data.anime === 'string' ? response.data.anime : null
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/**
|
|
@@ -47,4 +50,4 @@ class NekoGif {
|
|
|
47
50
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
module.exports = NekoGif
|
|
53
|
+
module.exports = NekoGif
|