Compare commits
No commits in common. "main" and "dev" have entirely different histories.
15 changed files with 416 additions and 110 deletions
|
@ -2,11 +2,11 @@ PORT=6600
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
#NODE_ENV=development
|
#NODE_ENV=development
|
||||||
|
|
||||||
|
#REDIS_HOST=127.0.0.1
|
||||||
|
#REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=pasw0rd
|
||||||
|
|
||||||
#REQUIRED if you want to use the e621 API
|
#REQUIRED if you want to use the e621 API
|
||||||
E621_USER_AGENT=YourApplication/1.0 (by username on e621)
|
E621_USER_AGENT=YourApplication/1.0 (by username on e621)
|
||||||
E621_USERNAME=your-username
|
E621_USERNAME=your-username
|
||||||
E621_API_KEY=your-apikey
|
E621_API_KEY=your-apikey
|
||||||
|
|
||||||
#REQUIRED if you want to use the Gelbooru API
|
|
||||||
GELBOORU_API_KEY=your-apikey
|
|
||||||
GELBOORU_USER_ID=your-user-id
|
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,4 +3,3 @@ bun.lockb
|
||||||
/config/secrets.ts
|
/config/secrets.ts
|
||||||
.env
|
.env
|
||||||
/logs
|
/logs
|
||||||
bun.lock
|
|
||||||
|
|
18
README.md
18
README.md
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.creations.works/creations/booru-api
|
git clone <repository-url>
|
||||||
cd booru-api
|
cd booru-api
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -48,21 +48,13 @@
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Note**
|
> **Note**
|
||||||
> To use the **e621 API**, you must update the following environment variables in your `.env` file:
|
> To use the e621 API, you must update the following environment variables in your `.env` file:
|
||||||
>
|
>
|
||||||
> ```env
|
> ```env
|
||||||
> E621_USER_AGENT=YourApplication/1.0 (by username on e621)
|
> # REQUIRED if you want to use the e621 API
|
||||||
|
> E621_USER_AGENT=YourApplicationName/1.0 (by username on e621)
|
||||||
> E621_USERNAME=your-username
|
> E621_USERNAME=your-username
|
||||||
> E621_API_KEY=your-apikey
|
> E621_API_KEY=your-apikey
|
||||||
> ```
|
> ```
|
||||||
|
>
|
||||||
> Replace `your-username` and `your-apikey` with your e621 account credentials. Update the `User-Agent` string to include your application name, version, and a contact method (e.g., your e621 username) to comply with e621's API guidelines.
|
> Replace `your-username` and `your-apikey` with your e621 account credentials. Update the `User-Agent` string to include your application name, version, and a contact method (e.g., your e621 username) to comply with e621's API guidelines.
|
||||||
>
|
|
||||||
>
|
|
||||||
> To use the **Gelbooru API**, you must also update the following:
|
|
||||||
>
|
|
||||||
> ```env
|
|
||||||
> GELBOORU_API_KEY=your-apikey
|
|
||||||
> GELBOORU_USER_ID=your-user-id
|
|
||||||
> ```
|
|
||||||
> You can find these credentials in your [Gelbooru account settings](https://gelbooru.com/index.php?page=account&s=options).
|
|
||||||
> These are required for authenticated API requests and higher rate limits.
|
|
||||||
|
|
12
compose.yml
12
compose.yml
|
@ -10,6 +10,18 @@ services:
|
||||||
- "${PORT:-6600}:${PORT:-6600}"
|
- "${PORT:-6600}:${PORT:-6600}"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- dragonfly-redis
|
||||||
|
networks:
|
||||||
|
- booru-network
|
||||||
|
|
||||||
|
dragonfly-redis:
|
||||||
|
container_name: dragonfly-redis
|
||||||
|
image: docker.dragonflydb.io/dragonflydb/dragonfly
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
REDIS_PASSWORD: ${redis_password:-pasw0rd}
|
||||||
|
command: ["--requirepass", "${redis_password:-pasw0rd}"]
|
||||||
networks:
|
networks:
|
||||||
- booru-network
|
- booru-network
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// cSpell:disable
|
// cSpell:disable
|
||||||
|
|
||||||
import { gelBooruAUTH, getE621Auth } from "@config/environment";
|
import { getE621Auth } from "./environment";
|
||||||
|
|
||||||
const booruDefaults: IBooruDefaults = {
|
const booruDefaults: IBooruDefaults = {
|
||||||
search: "index.php?page=dapi&s=post&q=index&json=1",
|
search: "index.php?page=dapi&s=post&q=index&json=1",
|
||||||
|
@ -18,7 +18,7 @@ export const booruConfig: IBooruConfigMap = {
|
||||||
autocomplete: "ac.rule34.xxx/autocomplete.php?q=",
|
autocomplete: "ac.rule34.xxx/autocomplete.php?q=",
|
||||||
},
|
},
|
||||||
"realbooru.com": {
|
"realbooru.com": {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
name: "realbooru.com",
|
name: "realbooru.com",
|
||||||
aliases: ["realbooru", "rb", "real34", "realb"],
|
aliases: ["realbooru", "rb", "real34", "realb"],
|
||||||
endpoint: "realbooru.com",
|
endpoint: "realbooru.com",
|
||||||
|
@ -70,13 +70,4 @@ export const booruConfig: IBooruConfigMap = {
|
||||||
},
|
},
|
||||||
auth: getE621Auth(),
|
auth: getE621Auth(),
|
||||||
},
|
},
|
||||||
"gelbooru.com": {
|
|
||||||
enabled: true,
|
|
||||||
name: "gelbooru.com",
|
|
||||||
aliases: ["gelbooru", "gb", "gelboorucom"],
|
|
||||||
endpoint: "gelbooru.com",
|
|
||||||
autocomplete: "gelbooru.com/index.php?page=autocomplete&term=",
|
|
||||||
functions: booruDefaults,
|
|
||||||
auth: gelBooruAUTH(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,6 +8,12 @@ export const environment: Environment = {
|
||||||
process.argv.includes("--dev"),
|
process.argv.includes("--dev"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const redisConfig: RedisConfig = {
|
||||||
|
host: process.env.REDIS_HOST || "dragonfly-redis",
|
||||||
|
port: parseInt(process.env.REDIS_PORT || "6379", 10),
|
||||||
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!process.env.E621_USER_AGENT ||
|
!process.env.E621_USER_AGENT ||
|
||||||
!process.env.E621_USERNAME ||
|
!process.env.E621_USERNAME ||
|
||||||
|
@ -34,14 +40,3 @@ export function getE621Auth(): Record<string, string> {
|
||||||
"Basic " + btoa(`${e621Username || ""}:${e621ApiKey || ""}`),
|
"Basic " + btoa(`${e621Username || ""}:${e621ApiKey || ""}`),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!process.env.GELBOORU_API_KEY || !process.env.GELBOORU_USER_ID) {
|
|
||||||
logger.error("Missing Gelbooru credentials in .env file");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function gelBooruAUTH(): Record<string, string> {
|
|
||||||
return {
|
|
||||||
apiKey: process.env.GELBOORU_API_KEY || "",
|
|
||||||
userId: process.env.GELBOORU_USER_ID || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
@ -23,7 +23,11 @@
|
||||||
"dev": "bun run --watch src/index.ts --dev",
|
"dev": "bun run --watch src/index.ts --dev",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"lint:fix": "bun lint --fix",
|
"lint:fix": "bun lint --fix",
|
||||||
"cleanup": "rm -rf logs node_modules bun.lockb"
|
"cleanup": "rm -rf logs node_modules bun.lockdb"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"redis": "^4.7.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
182
src/database/redis.ts
Normal file
182
src/database/redis.ts
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
import { redisConfig } from "@config/environment";
|
||||||
|
import { logger } from "@helpers/logger";
|
||||||
|
import { createClient, type RedisClientType } from "redis";
|
||||||
|
|
||||||
|
class RedisJson {
|
||||||
|
private static instance: RedisJson | null = null;
|
||||||
|
private client: RedisClientType | null = null;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public static async initialize(): Promise<RedisJson> {
|
||||||
|
if (!RedisJson.instance) {
|
||||||
|
RedisJson.instance = new RedisJson();
|
||||||
|
RedisJson.instance.client = createClient({
|
||||||
|
socket: {
|
||||||
|
host: redisConfig.host,
|
||||||
|
port: redisConfig.port,
|
||||||
|
},
|
||||||
|
username: redisConfig.username || undefined,
|
||||||
|
password: redisConfig.password || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
RedisJson.instance.client.on("error", (err: Error) => {
|
||||||
|
logger.error("Redis connection error:");
|
||||||
|
logger.error((err as Error) || "Unknown error");
|
||||||
|
logger.error(redisConfig.host);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
RedisJson.instance.client.on("connect", () => {
|
||||||
|
logger.info([
|
||||||
|
"Connected to Redis on",
|
||||||
|
`${redisConfig.host}:${redisConfig.port}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await RedisJson.instance.client.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedisJson.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): RedisJson {
|
||||||
|
if (!RedisJson.instance || !RedisJson.instance.client) {
|
||||||
|
throw new Error(
|
||||||
|
"Redis instance not initialized. Call initialize() first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RedisJson.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
logger.error("Redis client is not initialized.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.client.disconnect();
|
||||||
|
this.client = null;
|
||||||
|
logger.info("Redis disconnected successfully.");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error disconnecting Redis client:");
|
||||||
|
logger.error(error as Error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(
|
||||||
|
type: "JSON" | "STRING",
|
||||||
|
key: string,
|
||||||
|
path?: string,
|
||||||
|
): Promise<
|
||||||
|
string | number | boolean | Record<string, unknown> | null | unknown
|
||||||
|
> {
|
||||||
|
if (!this.client) {
|
||||||
|
logger.error("Redis client is not initialized.");
|
||||||
|
throw new Error("Redis client is not initialized.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (type === "JSON") {
|
||||||
|
const value: unknown = await this.client.json.get(key, {
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
} else if (type === "STRING") {
|
||||||
|
const value: string | null = await this.client.get(key);
|
||||||
|
return value;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid type: ${type}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error getting value from Redis for key: ${key}`);
|
||||||
|
logger.error(error as Error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set(
|
||||||
|
type: "JSON" | "STRING",
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
expiresInSeconds?: number,
|
||||||
|
path?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
logger.error("Redis client is not initialized.");
|
||||||
|
throw new Error("Redis client is not initialized.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (type === "JSON") {
|
||||||
|
await this.client.json.set(key, path || "$", value as string);
|
||||||
|
|
||||||
|
if (expiresInSeconds) {
|
||||||
|
await this.client.expire(key, expiresInSeconds);
|
||||||
|
}
|
||||||
|
} else if (type === "STRING") {
|
||||||
|
if (expiresInSeconds) {
|
||||||
|
await this.client.set(key, value as string, {
|
||||||
|
EX: expiresInSeconds,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.client.set(key, value as string);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid type: ${type}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error setting value in Redis for key: ${key}`);
|
||||||
|
logger.error(error as Error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(type: "JSON" | "STRING", key: string): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
logger.error("Redis client is not initialized.");
|
||||||
|
throw new Error("Redis client is not initialized.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (type === "JSON") {
|
||||||
|
await this.client.json.del(key);
|
||||||
|
} else if (type === "STRING") {
|
||||||
|
await this.client.del(key);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid type: ${type}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error deleting value from Redis for key: ${key}`);
|
||||||
|
logger.error(error as Error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async expire(key: string, seconds: number): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
logger.error("Redis client is not initialized.");
|
||||||
|
throw new Error("Redis client is not initialized.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.client.expire(key, seconds);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error expiring key in Redis: ${key}`);
|
||||||
|
logger.error(error as Error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const redis: {
|
||||||
|
initialize: () => Promise<RedisJson>;
|
||||||
|
getInstance: () => RedisJson;
|
||||||
|
} = {
|
||||||
|
initialize: RedisJson.initialize,
|
||||||
|
getInstance: RedisJson.getInstance,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { RedisJson };
|
|
@ -1,5 +1,3 @@
|
||||||
/* eslint-disable prettier/prettier */
|
|
||||||
|
|
||||||
import { booruConfig } from "@config/booru";
|
import { booruConfig } from "@config/booru";
|
||||||
|
|
||||||
export function timestampToReadable(timestamp?: number): string {
|
export function timestampToReadable(timestamp?: number): string {
|
||||||
|
@ -76,7 +74,6 @@ export function determineBooru(
|
||||||
export function postExpectedFormat(
|
export function postExpectedFormat(
|
||||||
booru: IBooruConfig,
|
booru: IBooruConfig,
|
||||||
posts: BooruPost[] | BooruPost,
|
posts: BooruPost[] | BooruPost,
|
||||||
tag_format: string = "string",
|
|
||||||
): { posts: BooruPost[] } | null {
|
): { posts: BooruPost[] } | null {
|
||||||
if (!posts) return null;
|
if (!posts) return null;
|
||||||
|
|
||||||
|
@ -92,12 +89,9 @@ export function postExpectedFormat(
|
||||||
post_url:
|
post_url:
|
||||||
post.post_url ??
|
post.post_url ??
|
||||||
`https://${booru.endpoint}/posts/${post.id}`,
|
`https://${booru.endpoint}/posts/${post.id}`,
|
||||||
tags:
|
tags: Object.values(post.tags || {})
|
||||||
tag_format === "unformatted"
|
.flat()
|
||||||
? post.tags
|
.join(" "),
|
||||||
: Object.values(post.tags || {})
|
|
||||||
.flat()
|
|
||||||
.join(" "),
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
|
import { redis } from "@database/redis";
|
||||||
import { logger } from "@helpers/logger";
|
import { logger } from "@helpers/logger";
|
||||||
|
|
||||||
import { serverHandler } from "./server";
|
import { serverHandler } from "./server";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
await redis.initialize();
|
||||||
serverHandler.initialize();
|
serverHandler.initialize();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { determineBooru } from "@helpers/char";
|
import { determineBooru } from "@helpers/char";
|
||||||
import { fetch } from "bun";
|
import { fetch } from "bun";
|
||||||
|
|
||||||
|
import { redis } from "@/database/redis";
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@/helpers/logger";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
|
@ -16,6 +17,7 @@ async function handler(
|
||||||
query: Query,
|
query: Query,
|
||||||
params: Params,
|
params: Params,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
const { force } = query as { force: string };
|
||||||
const { booru, tag } = params as { booru: string; tag: string };
|
const { booru, tag } = params as { booru: string; tag: string };
|
||||||
|
|
||||||
if (!booru) {
|
if (!booru) {
|
||||||
|
@ -45,8 +47,6 @@ async function handler(
|
||||||
}
|
}
|
||||||
|
|
||||||
const booruConfig: IBooruConfig | null = determineBooru(booru);
|
const booruConfig: IBooruConfig | null = determineBooru(booru);
|
||||||
const isE621: boolean = booruConfig?.name === "e621.net";
|
|
||||||
const isGelbooru: boolean = booruConfig?.name === "gelbooru.com";
|
|
||||||
|
|
||||||
if (!booruConfig) {
|
if (!booruConfig) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
@ -74,6 +74,8 @@ async function handler(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isE621: boolean = booruConfig.name === "e621.net";
|
||||||
|
|
||||||
if (isE621 && tag.length < 3) {
|
if (isE621 && tag.length < 3) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
|
@ -93,6 +95,47 @@ async function handler(
|
||||||
}
|
}
|
||||||
editedTag = editedTag.replace(/\s/g, "_");
|
editedTag = editedTag.replace(/\s/g, "_");
|
||||||
|
|
||||||
|
const cacheKey: string = `nsfw:${booru}:autocomplete:${editedTag}`;
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
const cacheData: unknown = await redis
|
||||||
|
.getInstance()
|
||||||
|
.get("JSON", cacheKey);
|
||||||
|
|
||||||
|
if (cacheData) {
|
||||||
|
const dataAsType: { count: number; data: unknown } = cacheData as {
|
||||||
|
count: number;
|
||||||
|
data: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dataAsType.count === 0) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 404,
|
||||||
|
error: "No results found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
cache: true,
|
||||||
|
count: dataAsType.count,
|
||||||
|
data: dataAsType.data,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!booruConfig.autocomplete) {
|
if (!booruConfig.autocomplete) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
|
@ -106,15 +149,12 @@ async function handler(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let url: string = `https://${booruConfig.autocomplete}${editedTag}`;
|
const url: string = `https://${booruConfig.autocomplete}${editedTag}`;
|
||||||
|
|
||||||
if (isGelbooru) {
|
|
||||||
url += `&api_key=${booruConfig.auth?.api_key}&user_id=${booruConfig.auth?.user_id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: IBooruConfig["auth"] | undefined =
|
const headers: IBooruConfig["auth"] | undefined = booruConfig.auth
|
||||||
booruConfig.auth && isE621 ? booruConfig.auth : undefined;
|
? booruConfig.auth
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const response: Response = await fetch(url, {
|
const response: Response = await fetch(url, {
|
||||||
headers,
|
headers,
|
||||||
|
@ -163,6 +203,10 @@ async function handler(
|
||||||
const resultCount: number = (data as unknown[]).length;
|
const resultCount: number = (data as unknown[]).length;
|
||||||
|
|
||||||
if (resultCount === 0) {
|
if (resultCount === 0) {
|
||||||
|
await redis
|
||||||
|
.getInstance()
|
||||||
|
.set("JSON", cacheKey, { count: 0, data }, 60 * 60 * 2); // 2 hours
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
@ -175,10 +219,15 @@ async function handler(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await redis
|
||||||
|
.getInstance()
|
||||||
|
.set("JSON", cacheKey, { count: resultCount, data }, 60 * 60 * 24); // 24 hours
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
code: 200,
|
code: 200,
|
||||||
|
cache: false,
|
||||||
count: resultCount,
|
count: resultCount,
|
||||||
data,
|
data,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { determineBooru, postExpectedFormat } from "@helpers/char";
|
import { determineBooru, postExpectedFormat } from "@helpers/char";
|
||||||
import { fetch } from "bun";
|
import { fetch } from "bun";
|
||||||
|
|
||||||
|
import { redis } from "@/database/redis";
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@/helpers/logger";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
|
@ -16,9 +17,7 @@ async function handler(
|
||||||
query: Query,
|
query: Query,
|
||||||
params: Params,
|
params: Params,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { tag_format } = query as {
|
const { force } = query as { force: string };
|
||||||
tag_format: string;
|
|
||||||
};
|
|
||||||
const { booru, id } = params as { booru: string; id: string };
|
const { booru, id } = params as { booru: string; id: string };
|
||||||
|
|
||||||
if (!booru || !id) {
|
if (!booru || !id) {
|
||||||
|
@ -35,8 +34,6 @@ async function handler(
|
||||||
}
|
}
|
||||||
|
|
||||||
const booruConfig: IBooruConfig | null = determineBooru(booru);
|
const booruConfig: IBooruConfig | null = determineBooru(booru);
|
||||||
const isE621: boolean = booruConfig?.name === "e621.net";
|
|
||||||
const isGelbooru: boolean = booruConfig?.name === "gelbooru.com";
|
|
||||||
|
|
||||||
if (!booruConfig) {
|
if (!booruConfig) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
@ -67,19 +64,38 @@ async function handler(
|
||||||
const funcString: string | [string, string] = booruConfig.functions.id;
|
const funcString: string | [string, string] = booruConfig.functions.id;
|
||||||
let url: string = `https://${booruConfig.endpoint}/${booruConfig.functions.id}${id}`;
|
let url: string = `https://${booruConfig.endpoint}/${booruConfig.functions.id}${id}`;
|
||||||
|
|
||||||
if (isGelbooru) {
|
|
||||||
url += `&api_key=${booruConfig.auth?.api_key}&user_id=${booruConfig.auth?.user_id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(funcString)) {
|
if (Array.isArray(funcString)) {
|
||||||
const [start, end] = funcString;
|
const [start, end] = funcString;
|
||||||
|
|
||||||
url = `https://${booruConfig.endpoint}/${start}${id}${end}`;
|
url = `https://${booruConfig.endpoint}/${start}${id}${end}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey: string = `nsfw:${booru}:${id}`;
|
||||||
|
if (!force) {
|
||||||
|
const cacheData: unknown = await redis
|
||||||
|
.getInstance()
|
||||||
|
.get("JSON", cacheKey);
|
||||||
|
|
||||||
|
if (cacheData) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
cache: true,
|
||||||
|
post:
|
||||||
|
(cacheData as { posts: BooruPost[] }).posts[0] || null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: IBooruConfig["auth"] | undefined =
|
const headers: IBooruConfig["auth"] | undefined = booruConfig.auth
|
||||||
booruConfig.auth && isE621 ? booruConfig.auth : undefined;
|
? booruConfig.auth
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const response: Response = await fetch(url, {
|
const response: Response = await fetch(url, {
|
||||||
headers,
|
headers,
|
||||||
|
@ -150,7 +166,6 @@ async function handler(
|
||||||
const expectedData: { posts: BooruPost[] } | null = postExpectedFormat(
|
const expectedData: { posts: BooruPost[] } | null = postExpectedFormat(
|
||||||
booruConfig,
|
booruConfig,
|
||||||
posts,
|
posts,
|
||||||
tag_format,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!expectedData) {
|
if (!expectedData) {
|
||||||
|
@ -172,10 +187,13 @@ async function handler(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await redis.getInstance().set("JSON", cacheKey, expectedData, 60 * 30); // 30 minutes
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
code: 200,
|
code: 200,
|
||||||
|
cache: false,
|
||||||
post: expectedData?.posts[0] || null,
|
post: expectedData?.posts[0] || null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "@helpers/char";
|
} from "@helpers/char";
|
||||||
import { fetch } from "bun";
|
import { fetch } from "bun";
|
||||||
|
|
||||||
|
import { redis } from "@/database/redis";
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@/helpers/logger";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
|
@ -23,17 +24,16 @@ async function handler(
|
||||||
query: Query,
|
query: Query,
|
||||||
params: Params,
|
params: Params,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
const { force } = query as { force: string };
|
||||||
const { booru } = params as { booru: string };
|
const { booru } = params as { booru: string };
|
||||||
const {
|
const {
|
||||||
tags,
|
tags,
|
||||||
results = 5,
|
results = 5,
|
||||||
excludeTags,
|
excludeTags,
|
||||||
tag_format = "formatted",
|
|
||||||
} = requestBody as {
|
} = requestBody as {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
results: number;
|
results: number;
|
||||||
excludeTags: string[];
|
excludeTags: string[];
|
||||||
tag_format: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!booru) {
|
if (!booru) {
|
||||||
|
@ -120,7 +120,6 @@ async function handler(
|
||||||
}
|
}
|
||||||
|
|
||||||
const isE621: boolean = booruConfig.name === "e621.net";
|
const isE621: boolean = booruConfig.name === "e621.net";
|
||||||
const isGelbooru: boolean = booruConfig.name === "gelbooru.com";
|
|
||||||
|
|
||||||
const formattedTags: string = tags ? tagsToExpectedFormat(tags) : "";
|
const formattedTags: string = tags ? tagsToExpectedFormat(tags) : "";
|
||||||
const formattedExcludeTags: string = excludeTags
|
const formattedExcludeTags: string = excludeTags
|
||||||
|
@ -164,24 +163,36 @@ async function handler(
|
||||||
parts.push("&");
|
parts.push("&");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGelbooru) {
|
|
||||||
parts.push("api_key");
|
|
||||||
parts.push(booruConfig.auth?.apiKey || "");
|
|
||||||
parts.push("&");
|
|
||||||
parts.push("user_id");
|
|
||||||
parts.push(booruConfig.auth?.userId || "");
|
|
||||||
parts.push("&");
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryParams: string = [tagsString(), page, resultsString]
|
const queryParams: string = [tagsString(), page, resultsString]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("&");
|
.join("&");
|
||||||
parts.push(queryParams);
|
parts.push(queryParams);
|
||||||
|
|
||||||
console.log("URL", parts.join(""));
|
|
||||||
return parts.join("");
|
return parts.join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const noResultsCacheKey: string = `nsfw:${booru}:random:noResults:${tagsString()}`;
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
const cacheData: unknown = await redis
|
||||||
|
.getInstance()
|
||||||
|
.get("JSON", noResultsCacheKey);
|
||||||
|
|
||||||
|
if (cacheData) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 404,
|
||||||
|
cache: true,
|
||||||
|
error: "No posts found with the given tags",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const config: { maxPage: number; maxTries: number } = {
|
const config: { maxPage: number; maxTries: number } = {
|
||||||
maxPage: 12,
|
maxPage: 12,
|
||||||
maxTries: 6,
|
maxTries: 6,
|
||||||
|
@ -189,6 +200,44 @@ async function handler(
|
||||||
let state: { tries: number; page: number } = { tries: 0, page: 16 };
|
let state: { tries: number; page: number } = { tries: 0, page: 16 };
|
||||||
|
|
||||||
while (state.tries < config.maxTries) {
|
while (state.tries < config.maxTries) {
|
||||||
|
if (state.tries === config.maxTries) {
|
||||||
|
state.page = 0;
|
||||||
|
} else {
|
||||||
|
const oldPage: number = state.page;
|
||||||
|
|
||||||
|
do {
|
||||||
|
state.page = Math.floor(Math.random() * config.maxPage);
|
||||||
|
} while (state.page === oldPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey: string = `nsfw:${booru}:random:${tagsString()}:${results}:${state.page}`;
|
||||||
|
if (!force) {
|
||||||
|
const cacheData: unknown = await redis
|
||||||
|
.getInstance()
|
||||||
|
.get("JSON", cacheKey);
|
||||||
|
|
||||||
|
if (cacheData) {
|
||||||
|
const minimizedPosts: BooruPost[] = minPosts(
|
||||||
|
(cacheData as { posts: BooruPost[] }).posts,
|
||||||
|
results,
|
||||||
|
);
|
||||||
|
|
||||||
|
const shuffledPosts: BooruPost[] = shufflePosts(minimizedPosts);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
cache: true,
|
||||||
|
posts: shuffledPosts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const url: string = getUrl(pageString(state.page), resultsString);
|
const url: string = getUrl(pageString(state.page), resultsString);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -232,7 +281,7 @@ async function handler(
|
||||||
const parsedData: Data = data as Data;
|
const parsedData: Data = data as Data;
|
||||||
|
|
||||||
let posts: BooruPost[] = [];
|
let posts: BooruPost[] = [];
|
||||||
if (booruConfig.name === "realbooru.com" || isGelbooru) {
|
if (booruConfig.name === "realbooru.com") {
|
||||||
posts = parsedData.post || [];
|
posts = parsedData.post || [];
|
||||||
} else {
|
} else {
|
||||||
if (parsedData.post) {
|
if (parsedData.post) {
|
||||||
|
@ -247,17 +296,22 @@ async function handler(
|
||||||
if (posts.length === 0) continue;
|
if (posts.length === 0) continue;
|
||||||
|
|
||||||
let expectedData: { posts: BooruPost[] } | null =
|
let expectedData: { posts: BooruPost[] } | null =
|
||||||
postExpectedFormat(booruConfig, posts, tag_format);
|
postExpectedFormat(booruConfig, posts);
|
||||||
|
|
||||||
if (!expectedData) continue;
|
if (!expectedData) continue;
|
||||||
|
|
||||||
expectedData.posts = shufflePosts(expectedData.posts);
|
|
||||||
expectedData.posts = minPosts(expectedData.posts, results);
|
expectedData.posts = minPosts(expectedData.posts, results);
|
||||||
|
expectedData.posts = shufflePosts(expectedData.posts);
|
||||||
|
|
||||||
|
await redis
|
||||||
|
.getInstance()
|
||||||
|
.set("JSON", cacheKey, expectedData, 60 * 60 * 1); // 1 hours
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
code: 200,
|
code: 200,
|
||||||
|
cache: false,
|
||||||
posts: expectedData.posts,
|
posts: expectedData.posts,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -268,18 +322,11 @@ async function handler(
|
||||||
continue;
|
continue;
|
||||||
} finally {
|
} finally {
|
||||||
state.tries++;
|
state.tries++;
|
||||||
|
|
||||||
if (state.tries >= config.maxTries - 1) {
|
|
||||||
state.page = 0;
|
|
||||||
} else {
|
|
||||||
const oldPage: number = state.page;
|
|
||||||
do {
|
|
||||||
state.page = Math.floor(Math.random() * config.maxPage);
|
|
||||||
} while (state.page === oldPage);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await redis.getInstance().set("JSON", noResultsCacheKey, true, 60 * 30); // 30 minutes
|
||||||
|
|
||||||
logger.error([
|
logger.error([
|
||||||
"No posts found",
|
"No posts found",
|
||||||
`Booru: ${booru}`,
|
`Booru: ${booru}`,
|
||||||
|
|
|
@ -5,6 +5,8 @@ import {
|
||||||
} from "@helpers/char";
|
} from "@helpers/char";
|
||||||
import { fetch } from "bun";
|
import { fetch } from "bun";
|
||||||
|
|
||||||
|
import { redis } from "@/database/redis";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
accepts: "*/*",
|
accepts: "*/*",
|
||||||
|
@ -19,19 +21,18 @@ async function handler(
|
||||||
query: Query,
|
query: Query,
|
||||||
params: Params,
|
params: Params,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
const { force } = query as { force: string };
|
||||||
const { booru } = params as { booru: string };
|
const { booru } = params as { booru: string };
|
||||||
const {
|
const {
|
||||||
page = 0,
|
page = 0,
|
||||||
tags,
|
tags,
|
||||||
results = 5,
|
results = 5,
|
||||||
excludeTags,
|
excludeTags,
|
||||||
tag_format = "formatted",
|
|
||||||
} = requestBody as {
|
} = requestBody as {
|
||||||
page: 0;
|
page: 0;
|
||||||
tags: string[] | string;
|
tags: string[] | string;
|
||||||
results: number;
|
results: number;
|
||||||
excludeTags: string[] | string;
|
excludeTags: string[] | string;
|
||||||
tag_format: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!booru) {
|
if (!booru) {
|
||||||
|
@ -118,7 +119,6 @@ async function handler(
|
||||||
}
|
}
|
||||||
|
|
||||||
const isE621: boolean = booruConfig.name === "e621.net";
|
const isE621: boolean = booruConfig.name === "e621.net";
|
||||||
const isGelbooru: boolean = booruConfig.name === "gelbooru.com";
|
|
||||||
|
|
||||||
const formattedTags: string = tags ? tagsToExpectedFormat(tags) : "";
|
const formattedTags: string = tags ? tagsToExpectedFormat(tags) : "";
|
||||||
const formattedExcludeTags: string = excludeTags
|
const formattedExcludeTags: string = excludeTags
|
||||||
|
@ -162,15 +162,6 @@ async function handler(
|
||||||
parts.push("&");
|
parts.push("&");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGelbooru) {
|
|
||||||
parts.push("api_key");
|
|
||||||
parts.push(booruConfig.auth?.apiKey || "");
|
|
||||||
parts.push("&");
|
|
||||||
parts.push("user_id");
|
|
||||||
parts.push(booruConfig.auth?.userId || "");
|
|
||||||
parts.push("&");
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryParams: string = [tagsString(), pageString(), resultsString]
|
const queryParams: string = [tagsString(), pageString(), resultsString]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("&");
|
.join("&");
|
||||||
|
@ -179,6 +170,27 @@ async function handler(
|
||||||
return parts.join("");
|
return parts.join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cacheKey: string = `nsfw:${booru}:${formattedTags}:${formattedExcludeTags}:${safePage}:${safeResults}`;
|
||||||
|
if (!force) {
|
||||||
|
const cacheData: unknown = await redis
|
||||||
|
.getInstance()
|
||||||
|
.get("JSON", cacheKey);
|
||||||
|
|
||||||
|
if (cacheData) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
cache: true,
|
||||||
|
posts: (cacheData as { posts: BooruPost[] }).posts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: IBooruConfig["auth"] | undefined = booruConfig.auth
|
const headers: IBooruConfig["auth"] | undefined = booruConfig.auth
|
||||||
? booruConfig.auth
|
? booruConfig.auth
|
||||||
|
@ -244,7 +256,6 @@ async function handler(
|
||||||
const expectedData: { posts: BooruPost[] } | null = postExpectedFormat(
|
const expectedData: { posts: BooruPost[] } | null = postExpectedFormat(
|
||||||
booruConfig,
|
booruConfig,
|
||||||
posts,
|
posts,
|
||||||
tag_format,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!expectedData) {
|
if (!expectedData) {
|
||||||
|
@ -260,10 +271,13 @@ async function handler(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await redis.getInstance().set("JSON", cacheKey, expectedData, 60 * 30); // 30 minutes
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
code: 200,
|
code: 200,
|
||||||
|
cache: false,
|
||||||
posts: expectedData.posts,
|
posts: expectedData.posts,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
7
types/config.d.ts
vendored
7
types/config.d.ts
vendored
|
@ -4,6 +4,13 @@ type Environment = {
|
||||||
development: boolean;
|
development: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RedisConfig = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type IBooruDefaults = {
|
type IBooruDefaults = {
|
||||||
search: string;
|
search: string;
|
||||||
random: string;
|
random: string;
|
||||||
|
|
Loading…
Add table
Reference in a new issue