Merge pull request #34 from ghoshRitesh12/advanced-search

Add advanced search feature
This commit is contained in:
Ritesh Ghosh
2024-03-25 19:02:36 +05:30
committed by GitHub
9 changed files with 338 additions and 95 deletions
+33 -2
View File
@@ -401,24 +401,50 @@ console.log(data);
#### Endpoint
```sh
# basic example
https://api-aniwatch.onrender.com/anime/search?q={query}&page={page}
# advanced example
https://api-aniwatch.onrender.com/anime/search?q={query}&genres={genres}&type={type}&sort={sort}&season={season}&language={sub_or_dub}&status={status}&rated={rating}&start_date={yyyy-mm-dd}&end_date={yyyy-mm-dd}&score={score}
```
#### Query Parameters
| Parameter | Type | Description | Required? | Default |
| :-------: | :----: | :---------------------------------------------------------------: | :-------: | :-----: |
| :----------: | :----: | :---------------------------------------------------------------: | :-------: | :-----: |
| `q` | string | The search query, i.e. the title of the item you are looking for. | Yes | -- |
| `page` | number | The page number of the result. | No | `1` |
| `type` | string | Type of the anime. eg: `movie` | No | -- |
| `status` | string | Status of the anime. eg: `finished-airing` | No | -- |
| `rated` | string | Rating of the anime. eg: `r+` or `pg-13` | No | -- |
| `score` | string | Score of the anime. eg: `good` or `very-good` | No | -- |
| `season` | string | Season of the aired anime. eg: `spring` | No | -- |
| `language` | string | Language category of the anime. eg: `sub` or `sub-&-dub` | No | -- |
| `start_date` | string | Start date of the anime(yyyy-mm-dd). eg: `2014-10-2` | No | -- |
| `end_date` | string | End date of the anime(yyyy-mm-dd). eg: `2010-12-4` | No | -- |
| `sort` | string | Order of sorting the anime result. eg: `recently-added` | No | -- |
| `genres` | string | Genre of the anime, separated by commas. eg: `isekai,shounen` | No | -- |
> [!TIP]
> For both `start_date` and `end_date`, year must be mentioned. If you wanna omit date or month specify `0` instead.
> Eg: omitting date -> 2014-10-0, omitting month -> 2014-0-12, omitting both -> 2014-0-0
#### Request sample
```javascript
// basic example
const resp = await fetch(
"https://api-aniwatch.onrender.com/anime/search?q=titan&page=1"
);
const data = await resp.json();
console.log(data);
// advanced example
const resp = await fetch(
"https://api-aniwatch.onrender.com/anime/search?q=girls&genres=action,adventure&type=movie&sort=score&season=spring&language=dub&status=finished-airing&rated=pg-13&start_date=2014-0-0&score=good"
);
const data = await resp.json();
console.log(data);
```
#### Response Schema
@@ -456,7 +482,12 @@ console.log(data);
],
currentPage: 1,
totalPages: 1,
hasNextPage: false
hasNextPage: false,
searchQuery: string,
searchFilters: {
[filter_name]: [filter_value]
...
}
}
```
+32 -9
View File
@@ -1,7 +1,24 @@
import createHttpError from "http-errors";
import { type RequestHandler } from "express";
import { scrapeAnimeSearch } from "../parsers/index.js";
import { type AnimeSearchQueryParams } from "../types/controllers/index.js";
import type {
SearchFilters,
AnimeSearchQueryParams,
} from "../types/controllers/index.js";
const searchFilters: Record<string, boolean> = {
filter: true,
type: true,
status: true,
rated: true,
score: true,
season: true,
language: true,
start_date: true,
end_date: true,
sort: true,
genres: true,
} as const;
// /anime/search?q=${query}&page=${page}
const getAnimeSearch: RequestHandler<
@@ -11,18 +28,24 @@ const getAnimeSearch: RequestHandler<
AnimeSearchQueryParams
> = async (req, res, next) => {
try {
const query: string | null = req.query.q
? decodeURIComponent(req.query.q as string)
: null;
const page: number = req.query.page
? Number(decodeURIComponent(req.query?.page as string))
: 1;
let { q: query, page, ...filters } = req.query;
if (query === null) {
query = query ? decodeURIComponent(query) : undefined;
const pageNo = page ? Number(decodeURIComponent(page as string)) : 1;
if (query === undefined) {
throw createHttpError.BadRequest("Search keyword required");
}
const data = await scrapeAnimeSearch(query, page);
const parsedFilters: SearchFilters = {};
for (const key in filters) {
if (searchFilters[key]) {
parsedFilters[key as keyof SearchFilters] =
filters[key as keyof SearchFilters];
}
}
const data = await scrapeAnimeSearch(query, pageNo, parsedFilters);
res.status(200).json(data);
} catch (err: any) {
+37 -7
View File
@@ -3,18 +3,22 @@ import {
ACCEPT_HEADER,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
extractMostPopularAnimes,
extractAnimes,
getSearchFilterValue,
extractMostPopularAnimes,
getSearchDateFilterValue,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import createHttpError, { type HttpError } from "http-errors";
import { load, type CheerioAPI, type SelectorType } from "cheerio";
import type { ScrapedAnimeSearchResult } from "../types/parsers/index.js";
import type { SearchFilters, FilterKeys } from "../types/controllers/index.js";
// /anime/search?q=${query}&page=${page}
async function scrapeAnimeSearch(
q: string,
page: number = 1
page: number = 1,
filters: SearchFilters
): Promise<ScrapedAnimeSearchResult | HttpError> {
const res: ScrapedAnimeSearchResult = {
animes: [],
@@ -22,19 +26,45 @@ async function scrapeAnimeSearch(
currentPage: Number(page),
hasNextPage: false,
totalPages: 1,
searchQuery: q,
searchFilters: filters,
};
try {
const mainPage = await axios.get(
`${SRC_SEARCH_URL}?keyword=${q}&page=${page}`,
{
const url = new URL(SRC_SEARCH_URL);
url.searchParams.set("keyword", q);
url.searchParams.set("page", `${page}`);
url.searchParams.set("sort", "default");
for (const key in filters) {
if (key.includes("_date")) {
const dates = getSearchDateFilterValue(
key === "start_date",
filters[key as keyof SearchFilters] || ""
);
if (!dates) continue;
dates.map((dateParam) => {
const [key, val] = dateParam.split("=");
url.searchParams.set(key, val);
});
continue;
}
const filterVal = getSearchFilterValue(
key as FilterKeys,
filters[key as keyof SearchFilters] || ""
);
filterVal && url.searchParams.set(key, filterVal);
}
const mainPage = await axios.get(url.href, {
headers: {
"User-Agent": USER_AGENT_HEADER,
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
Accept: ACCEPT_HEADER,
},
}
);
});
const $: CheerioAPI = load(mainPage.data);
+16
View File
@@ -1,4 +1,20 @@
export type AnimeSearchQueryParams = {
q?: string;
page?: string;
type?: string;
status?: string;
rated?: string;
score?: string;
season?: string;
language?: string;
start_date?: string;
end_date?: string;
sort?: string;
genres?: string;
};
export type SearchFilters = Omit<AnimeSearchQueryParams, "q" | "page">;
export type FilterKeys = Partial<
keyof Omit<SearchFilters, "start_date" | "end_date">
>;
+10 -35
View File
@@ -1,35 +1,10 @@
import type {
GenreAnimePathParams,
GenreAnimeQueryParams,
} from "./animeGenre.js";
import type {
CategoryAnimePathParams,
CategoryAnimeQueryParams,
} from "./animeCategory.js";
import type {
AnimeProducerPathParams,
AnimeProducerQueryParams,
} from "./animeProducer.js";
import type { AnimeSearchQueryParams } from "./animeSearch.js";
import type { AnimeEpisodePathParams } from "./animeEpisodes.js";
import type { EpisodeServersQueryParams } from "./episodeServers.js";
import type { AnimeAboutInfoQueryParams } from "./animeAboutInfo.js";
import type { AnimeEpisodeSrcsQueryParams } from "./animeEpisodeSrcs.js";
import type { EstimatedScheduleQueryParams } from "./estimatedSchedule.js";
import type { AnimeSearchSuggestQueryParams } from "./animeSearchSuggestion.js";
export type {
GenreAnimePathParams,
GenreAnimeQueryParams,
AnimeSearchQueryParams,
AnimeEpisodePathParams,
AnimeProducerPathParams,
CategoryAnimePathParams,
AnimeProducerQueryParams,
CategoryAnimeQueryParams,
AnimeAboutInfoQueryParams,
EpisodeServersQueryParams,
AnimeEpisodeSrcsQueryParams,
EstimatedScheduleQueryParams,
AnimeSearchSuggestQueryParams,
};
export type * from "./animeGenre.js";
export type * from "./animeCategory.js";
export type * from "./animeProducer.js";
export type * from "./animeSearch.js";
export type * from "./animeEpisodes.js";
export type * from "./episodeServers.js";
export type * from "./animeAboutInfo.js";
export type * from "./animeEpisodeSrcs.js";
export type * from "./estimatedSchedule.js";
export type * from "./animeSearchSuggestion.js";
+3
View File
@@ -4,8 +4,11 @@ import type {
} from "./animeCategory.js";
import type { HttpError } from "http-errors";
import type { MostPopularAnime } from "../anime.js";
import type { SearchFilters } from "../controllers/animeSearch.js";
export interface ScrapedAnimeSearchResult
extends Pick<ScrapedAnimeCategory, CommonAnimeScrapeTypes> {
mostPopularAnimes: Array<MostPopularAnime> | HttpError;
searchQuery: string;
searchFilters: SearchFilters;
}
+113 -2
View File
@@ -1,11 +1,10 @@
import { config } from "dotenv";
config();
export const ACCEPT_ENCODING_HEADER = "gzip, deflate, br";
export const USER_AGENT_HEADER =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4692.71 Safari/537.36";
export const ACCEPT_HEADER =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9";
@@ -16,3 +15,115 @@ export const SRC_BASE_URL = `https://${DOMAIN}`;
export const SRC_AJAX_URL = `${SRC_BASE_URL}/ajax`;
export const SRC_HOME_URL = `${SRC_BASE_URL}/home`;
export const SRC_SEARCH_URL = `${SRC_BASE_URL}/search`;
// <SearchPageFilters>
export const genresIdMap: Record<string, number> = {
action: 1,
adventure: 2,
cars: 3,
comedy: 4,
dementia: 5,
demons: 6,
drama: 8,
ecchi: 9,
fantasy: 10,
game: 11,
harem: 35,
historical: 13,
horror: 14,
isekai: 44,
josei: 43,
kids: 15,
magic: 16,
"martial-arts": 17,
mecha: 18,
military: 38,
music: 19,
mystery: 7,
parody: 20,
police: 39,
psychological: 40,
romance: 22,
samurai: 21,
school: 23,
"sci-fi": 24,
seinen: 42,
shoujo: 25,
"shoujo-ai": 26,
shounen: 27,
"shounen-ai": 28,
"slice-of-life": 36,
space: 29,
sports: 30,
"super-power": 31,
supernatural: 37,
thriller: 41,
vampire: 32,
} as const;
export const typeIdMap: Record<string, number> = {
all: 0,
movie: 1,
tv: 2,
ova: 3,
ona: 4,
special: 5,
music: 6,
} as const;
export const statusIdMap: Record<string, number> = {
all: 0,
"finished-airing": 1,
"currently-airing": 2,
"not-yet-aired": 3,
} as const;
export const ratedIdMap: Record<string, number> = {
all: 0,
g: 1,
pg: 2,
"pg-13": 3,
r: 4,
"r+": 5,
rx: 6,
} as const;
export const scoreIdMap: Record<string, number> = {
all: 0,
appalling: 1,
horrible: 2,
"very-bad": 3,
bad: 4,
average: 5,
fine: 6,
good: 7,
"very-good": 8,
great: 9,
masterpiece: 10,
} as const;
export const seasonIdMap: Record<string, number> = {
all: 0,
spring: 1,
summer: 2,
fall: 3,
winter: 4,
} as const;
export const languageIdMap: Record<string, number> = {
all: 0,
sub: 1,
dub: 2,
"sub-&-dub": 3,
} as const;
export const sortIdMap: Record<string, string> = {
default: "default",
"recently-added": "recently_added",
"recently-updated": "recently_updated",
score: "score",
"name-a-z": "name_az",
"released-date": "released_date",
"most-watched": "most_watched",
} as const;
// </SearchPageFilters>
+2 -33
View File
@@ -1,33 +1,2 @@
import {
SRC_AJAX_URL,
SRC_HOME_URL,
SRC_BASE_URL,
ACCEPT_HEADER,
SRC_SEARCH_URL,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
} from "./constants.js";
import {
extractAnimes,
substringAfter,
substringBefore,
retrieveServerId,
extractTop10Animes,
extractMostPopularAnimes,
} from "./methods.js";
export {
SRC_AJAX_URL,
SRC_HOME_URL,
SRC_BASE_URL,
ACCEPT_HEADER,
SRC_SEARCH_URL,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
extractAnimes,
substringAfter,
substringBefore,
retrieveServerId,
extractTop10Animes,
extractMostPopularAnimes,
};
export * from "./methods.js";
export * from "./constants.js";
+85
View File
@@ -5,6 +5,17 @@ import type {
Top10AnimeTimePeriod,
} from "../types/anime.js";
import type { CheerioAPI, SelectorType } from "cheerio";
import {
genresIdMap,
languageIdMap,
ratedIdMap,
scoreIdMap,
seasonIdMap,
sortIdMap,
statusIdMap,
typeIdMap,
} from "./constants.js";
import { type FilterKeys } from "../types/controllers/animeSearch.js";
import createHttpError, { HttpError } from "http-errors";
export const extractAnimes = (
@@ -192,6 +203,80 @@ export function retrieveServerId(
);
}
function getGenresFilterVal(genreNames: string[]): string | undefined {
if (genreNames.length < 1) {
return undefined;
}
return genreNames.map((name) => genresIdMap[name]).join(",");
}
export function getSearchFilterValue(
key: FilterKeys,
rawValue: string
): string | undefined {
rawValue = rawValue.trim();
if (!rawValue) return undefined;
switch (key) {
case "genres": {
return getGenresFilterVal(rawValue.split(","));
}
case "type": {
const val = typeIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "status": {
const val = statusIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "rated": {
const val = ratedIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "score": {
const val = scoreIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "season": {
const val = seasonIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "language": {
const val = languageIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "sort": {
return sortIdMap[rawValue] ?? undefined;
}
default:
return undefined;
}
}
// this fn tackles both start_date and end_date
export function getSearchDateFilterValue(
isStartDate: boolean,
rawValue: string
): string[] | undefined {
rawValue = rawValue.trim();
if (!rawValue) return undefined;
const dateRegex = /^\d{4}-([0-9]|1[0-2])-([0-9]|[12][0-9]|3[01])$/;
const dateCategory = isStartDate ? "s" : "e";
const [year, month, date] = rawValue.split("-");
if (!dateRegex.test(rawValue)) {
return undefined;
}
// sample return -> [sy=2023, sm=10, sd=11]
return [
Number(year) > 0 ? `${dateCategory}y=${year}` : "",
Number(month) > 0 ? `${dateCategory}m=${month}` : "",
Number(date) > 0 ? `${dateCategory}d=${date}` : "",
].filter((d) => Boolean(d));
}
export function substringAfter(str: string, toFind: string) {
const index = str.indexOf(toFind);
return index == -1 ? "" : str.substring(index + toFind.length);