-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.ts
219 lines (180 loc) · 7.15 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
import type { RequestInfo, RequestInit } from 'node-fetch';
const fetch = (url: URL | RequestInfo, init?: RequestInit) =>
import('node-fetch').then(({ default: fetch }) => fetch(url, init));
const IMAGE_CATEGORIES = ["kitsune", "neko", "husbando", "waifu"] as const;
const GIF_CATEGORIES = [
"baka", "bite", "blush",
"bored", "cry", "cuddle",
"dance", "facepalm", "feed",
"happy", "highfive", "hug",
"kiss", "laugh", "pat",
"pout", "shrug", "slap",
"sleep", "smile", "smug",
"stare", "think", "thumbsup",
"tickle", "wave", "wink",
"kick", "handhold", "punch",
"shoot", "yeet", "poke",
"nod", "nom", "nope",
"handshake", "lurk", "peck",
"yawn",
] as const;
interface RatelimitData {
remaining: number,
resetsIn: number,
}
type Nullable<T> = T | undefined | null;
export type NbCategories = typeof GIF_CATEGORIES[number] | typeof IMAGE_CATEGORIES[number];
/** @deprecated This will be removed in the next major version */
export type NbEndpointMetadata = Record<string, {
format: string;
min: string;
max: string;
}>;
// NbResponse was taken :(
export type NbIndividualResponse = {
artist_href?: string
artist_name?: string
source_url?: string
anime_name?: string
url: string
}
export type NbResponse = { results: NbIndividualResponse[] }
export type NbBufferResponse = NbIndividualResponse & { data: Buffer }
export type RatelimitHandleMode = "sleep" | "throw";
export interface ClientOptions {
ratelimitHandleMode: RatelimitHandleMode
}
/**
* A quick function to fetch a random file URL along with its metadata (if available).
*
* If you are going to call this function multiple times, it's better to initialize a new `Client` instead.
*
* @param category The category to fetch the file URL from. If omitted, it picks a random category.
*/
export async function fetchRandom(category?: NbCategories) {
return new Client().fetch(category, 1);
}
export class Client {
#ratelimitData: RatelimitData | null = null;
#clientOptions: ClientOptions;
constructor(clientOptions?: Partial<NbBufferResponse>) {
this.#clientOptions = {
ratelimitHandleMode: "sleep",
...clientOptions,
};
}
/**
* Fetch and download a random file with its metadata (if available).
* For more advanced options, you should use the `Client.fetch()` method and
* fetch the file by yourself.
*
* Refer to the documentation for more details: https://docs.nekos.best/api/endpoints.html#get-categoryfilenameformat
*
* @param category The category to download from.
*/
async fetchFile(category: Nullable<NbCategories> = null): Promise<NbBufferResponse> {
// The fetch() method performs the `category` validation
const fileDetails = (await this.fetch(category, 1)).results[0];
const file = (await fetchPath(void 0, fileDetails.url))
return {
...fileDetails,
data: Buffer.from(await file.arrayBuffer()),
}
}
/**
* Fetch multiple assets with their metadata (if available).
*
* Refer to the documentation for more details: https://docs.nekos.best/api/endpoints.html#get-categoryamountx
*
* @param category Category of assets. Set to `null` to pick a random category.
* @param amount The amount of assets. Refer to the documentation for the limits.
*/
async fetch(category: Nullable<NbCategories> = null, amount: number): Promise<NbResponse> {
if (!category) {
category = pickRandomCategory();
} else {
validateCategory(category);
}
if (!Number.isSafeInteger(amount)) {
throw new TypeError(`Expected a safe integer for amount. Got "${amount}".`);
}
return fetchJson(`${category}?amount=${amount}`);
}
/**
* Search for assets.
*
* Refer to the documentation for more details: https://docs.nekos.best/api/endpoints.html#get-searchqueryxtypexcategoryxamountx
*
* @param query Search query.
* @param category Category of assets. Set to `null` to pick a random category.
* @param amount The amount of assets. Refer to the documentation for the limits.
*/
async search(query: string, category: Nullable<NbCategories> = null, amount = 1): Promise<NbResponse> {
if (this.#ratelimitData != null) {
await handleRatelimit(this.#clientOptions.ratelimitHandleMode, this.#ratelimitData);
}
if (!category) {
category = pickRandomCategory();
} else {
validateCategory(category);
}
if (!Number.isSafeInteger(amount)) {
throw new TypeError(`Expected a safe integer for amount. Got "${amount}".`);
}
// Type 1 is images so if `category` is in `IMAGE_CATEGORIES`, the result will be 2 - 1 = 1
// Type 2 is for GIFs; 2 - 0 = 2
const type = 2 - +IMAGE_CATEGORIES.includes(category as never);
const response = await fetchPath(`search?query=${encodeURIComponent(query)}&type=${type}&category=${category}&amount=${amount}`);
const remaining = response.headers.get("x-rate-limit-remaining");
const resetsIn = response.headers.get("x-rate-limit-reset");
if (remaining != null && resetsIn != null) {
this.#ratelimitData = {
resetsIn: Date.parse(resetsIn),
remaining: Number(remaining),
}
}
return await response.json() as NbResponse;
}
}
// The parameters are a bit ugly but who cares, it's a private function
async function fetchPath(path?: string, fullUrl: Nullable<string> = null) {
const url = fullUrl || `https://nekos.best/api/v2/${path}`;
const response = await fetch(url, {
headers: { "User-Agent": "nekos-best.js / 6.0.0" },
redirect: "follow",
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to fetch url "${url}" (status code ${response.status}): ${text}`);
}
return response;
}
export default Client;
async function fetchJson<T>(path: string): Promise<T> {
return await (await fetchPath(path)).json() as T;
}
function validateCategory(category: string) {
if (!(IMAGE_CATEGORIES.includes(category as never) || GIF_CATEGORIES.includes(category as never))) {
throw new TypeError(`"${category}" is not a valid category. Available categories: ${IMAGE_CATEGORIES.join(", ")}, ${GIF_CATEGORIES.join(", ")}`);
}
}
function pickRandomCategory(): NbCategories {
const idx = Math.random() * (GIF_CATEGORIES.length + IMAGE_CATEGORIES.length) | 0;
if (idx < IMAGE_CATEGORIES.length) {
return IMAGE_CATEGORIES[idx];
}
return GIF_CATEGORIES[idx - IMAGE_CATEGORIES.length];
}
async function handleRatelimit(mode: RatelimitHandleMode, data: RatelimitData) {
const now = Date.now();
if (data.remaining <= 0 && data.resetsIn > now) {
switch (mode) {
case "sleep":
await new Promise(resolve => setTimeout(resolve, data.resetsIn - now));
return;
case "throw":
throw Error("You are being ratelimited");
}
}
--data.remaining;
}