UNPKG

nekos-best.js

Version:

The official JavaScript wrapper for the https://nekos.best API with TypeScript typings.

282 lines (243 loc) 7.85 kB
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", "angry", "run" ] 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; }