Merge pull request #34 from ghoshRitesh12/advanced-search
Add advanced search feature
This commit is contained in:
@@ -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` |
|
||||
| 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]
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+41
-11
@@ -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}`,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": USER_AGENT_HEADER,
|
||||
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
|
||||
Accept: ACCEPT_HEADER,
|
||||
},
|
||||
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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user