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 +84 -76
- package/README.md +1 -2
- package/package.json +68 -69
- package/sources/classes/SFW.js +313 -313
package/CHANGELOG.md
CHANGED
|
@@ -1,76 +1,84 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## [2.3.
|
|
4
|
-
|
|
5
|
-
###
|
|
6
|
-
- La URL por defecto del API pasa a `https://maplebot.fyi/api/sfw`. El
|
|
7
|
-
|
|
8
|
-
### Compatibility
|
|
9
|
-
- No cambia ninguna firma. Quien
|
|
10
|
-
|
|
11
|
-
## [2.3.
|
|
12
|
-
|
|
13
|
-
###
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
###
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- `SFW
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
- `
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
- Se
|
|
60
|
-
- `
|
|
61
|
-
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
"check": "
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
}
|
package/sources/classes/SFW.js
CHANGED
|
@@ -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;
|