feat(reviews): add reviews route
allows seeing all reviews with optional filters. works sans JS as well closes https://github.com/zyachel/libremdb/issues/25 closes https://codeberg.org/zyachel/libremdb/issues/19
This commit is contained in:
@ -126,7 +126,7 @@ const cleanName = (rawData: RawName) => {
|
||||
},
|
||||
})),
|
||||
credits: {
|
||||
total: misc.totalCredits.total,
|
||||
total: misc.totalCredits?.total ?? null,
|
||||
summary: {
|
||||
titleType: misc.creditSummary.titleTypeCategories.map(cat => ({
|
||||
total: cat.total,
|
||||
|
@ -34,3 +34,5 @@ export const resultTitleTypes = {
|
||||
],
|
||||
key: 'ttype',
|
||||
} as const;
|
||||
|
||||
export const findFilterable = ['q', 'exact', resultTitleTypes.key, resultTypes.key];
|
||||
|
@ -1,4 +1,7 @@
|
||||
export const titleKey = (titleId: string) => `title:${titleId}`;
|
||||
export const titleReviewsKey = (titleId: string, query = '') => `title:${titleId}|${query}`;
|
||||
export const titleReviewsCursoredKey = (titleId: string, paginationKey: string) =>
|
||||
`title:${titleId}|${paginationKey}`;
|
||||
export const nameKey = (nameId: string) => `name:${nameId}`;
|
||||
export const listKey = (listId: string, pageNum = '1') => `list:${listId}?page=${pageNum}`;
|
||||
export const findKey = (query: string) => `find:${query}`;
|
||||
|
36
src/utils/constants/titleReviewsFilters.ts
Normal file
36
src/utils/constants/titleReviewsFilters.ts
Normal file
@ -0,0 +1,36 @@
|
||||
export const ratings = {
|
||||
types: [
|
||||
{ name: 'All', val: '0' },
|
||||
{ name: '1', val: '1' },
|
||||
{ name: '2', val: '2' },
|
||||
{ name: '3', val: '3' },
|
||||
{ name: '4', val: '4' },
|
||||
{ name: '5', val: '5' },
|
||||
{ name: '6', val: '6' },
|
||||
{ name: '7', val: '7' },
|
||||
{ name: '8', val: '8' },
|
||||
{ name: '9', val: '9' },
|
||||
{ name: '10', val: '10' },
|
||||
],
|
||||
key: 'ratingFilter',
|
||||
} as const;
|
||||
|
||||
export const sortBy = {
|
||||
types: [
|
||||
{ name: 'Featured', val: 'curated' },
|
||||
{ name: 'Review Date', val: 'submissionDate' },
|
||||
{ name: 'Total Votes', val: 'totalVotes' },
|
||||
{ name: 'Prolific Reviewer', val: 'reviewVolume' },
|
||||
{ name: 'Review Rating', val: 'userRating' },
|
||||
],
|
||||
key: 'sort',
|
||||
} as const;
|
||||
|
||||
export const direction = {
|
||||
types: [
|
||||
{ name: 'Ascending', val: 'asc' },
|
||||
{ name: 'Descending', val: 'desc' },
|
||||
],
|
||||
key: 'dir',
|
||||
} as const;
|
||||
export const keys = ['spoiler', direction.key, sortBy.key, ratings.key];
|
113
src/utils/fetchers/titleReviews.ts
Normal file
113
src/utils/fetchers/titleReviews.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import axiosInstance from 'src/utils/axiosInstance';
|
||||
import { AppError } from 'src/utils/helpers';
|
||||
|
||||
const reviews = async (titleId: string, queryStr = '') => {
|
||||
try {
|
||||
// https://www.imdb.com/title/tt0364343/reviews?spoiler=hide&sort=curated&dir=desc&ratingFilter=0
|
||||
const res = await axiosInstance(`/title/${titleId}/reviews?${queryStr}`);
|
||||
const $ = cheerio.load(res.data);
|
||||
|
||||
const $main = $('#main > .article');
|
||||
const $meta = $main.children('.subpage_title_block');
|
||||
|
||||
const meta = {
|
||||
title: clean($meta.find('[itemprop="name"] > a')),
|
||||
year: clean($meta.find('[itemprop="name"] > .nobr')),
|
||||
image: $meta.find('img[itemprop="image"]').attr('src') ?? null,
|
||||
numReviews: clean($main.find('.lister > .header > div')),
|
||||
titleId,
|
||||
};
|
||||
|
||||
const $listItems = $main.find('.lister-list').children();
|
||||
|
||||
const list = getReviewsList($listItems, $);
|
||||
const cursor = $main.find('.lister > .load-more-data').attr('data-key') ?? null;
|
||||
|
||||
return { meta, list, cursor };
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404)
|
||||
throw new AppError('not found', 404, err.cause);
|
||||
|
||||
if (err instanceof AppError) throw err;
|
||||
|
||||
throw new AppError('something went wrong', 500, err instanceof Error ? err.cause : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
export default reviews;
|
||||
|
||||
const clean = <T extends cheerio.Cheerio<any>>(item: T) => item.text().trim();
|
||||
|
||||
export const cursoredReviews = async (
|
||||
titleId: string,
|
||||
paginationKey: string,
|
||||
queryStr = '',
|
||||
title: string | null = null
|
||||
) => {
|
||||
try {
|
||||
// https://www.imdb.com/title/tt0364343/reviews/_ajax?paginationKey=g4w6ddbmqyzdo6ic4oxwjnjqrtt4yaz53iptz6pna7cpyv35pjt6udc2oiyfzmrkb4drv33tz5tbvxqxw25ns6mwx3qym&sort=desc
|
||||
const res = await axiosInstance(
|
||||
`/title/${titleId}/reviews/_ajax?paginationKey=${paginationKey}&${queryStr}`
|
||||
);
|
||||
|
||||
const $ = cheerio.load(res.data);
|
||||
const $main = $('body > div');
|
||||
const $listItems = $main.children('.lister-list').children();
|
||||
|
||||
const list = getReviewsList($listItems, $);
|
||||
|
||||
const cursor = $main.children('.load-more-data').attr('data-key') ?? null;
|
||||
|
||||
return { meta: { title, titleId }, list, cursor };
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404)
|
||||
throw new AppError('not found', 404, err.cause);
|
||||
|
||||
if (err instanceof AppError) throw err;
|
||||
|
||||
throw new AppError('something went wrong', 500, err instanceof Error ? err.cause : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const getReviewsList = ($listItems: cheerio.Cheerio<cheerio.Element>, $: cheerio.CheerioAPI) => {
|
||||
return $listItems
|
||||
.map((_i, el) => {
|
||||
const reviewId = $(el).attr('data-review-id') ?? null;
|
||||
|
||||
const $reviewContainer = $(el).find('.lister-item-content');
|
||||
|
||||
const summary = clean($reviewContainer.children('a.title'));
|
||||
const rating = clean($reviewContainer.children('.ipl-ratings-bar'));
|
||||
const $by = $reviewContainer.find('.display-name-date .display-name-link a');
|
||||
const by = {
|
||||
name: clean($by),
|
||||
link: $by.attr('href') ?? null,
|
||||
};
|
||||
const date = clean($reviewContainer.find('.display-name-date .review-date'));
|
||||
const isSpoiler = $reviewContainer.children('.spoiler-warning').length > 0;
|
||||
const reviewHtml = $reviewContainer.find('.content > .text').html();
|
||||
const responses = clean($reviewContainer.find('.content > .actions').contents().first());
|
||||
// .contents()
|
||||
// .filter(function () {
|
||||
// return this.nodeType === 3;
|
||||
// })
|
||||
// .map(function () {
|
||||
// return $(this).text();
|
||||
// })
|
||||
// .get();
|
||||
|
||||
return {
|
||||
summary,
|
||||
reviewId,
|
||||
rating,
|
||||
by,
|
||||
date,
|
||||
isSpoiler,
|
||||
reviewHtml,
|
||||
responses,
|
||||
};
|
||||
})
|
||||
.toArray();
|
||||
};
|
@ -81,18 +81,13 @@ export const AppError = class extends Error {
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanQueryStr = (
|
||||
entries: [string, string][],
|
||||
filterable = ['q', 's', 'exact', 'ttype']
|
||||
) => {
|
||||
let queryStr = '';
|
||||
export const cleanQueryStr = (record: Record<string, string>, filterable: string[]) => {
|
||||
const urlSearchParams = new URLSearchParams();
|
||||
|
||||
entries.forEach(([key, val], i) => {
|
||||
if (!val || !filterable.includes(key)) return;
|
||||
queryStr += `${i > 0 ? '&' : ''}${key}=${val.trim()}`;
|
||||
filterable.forEach(key => {
|
||||
if (record[key]) urlSearchParams.append(key, record[key].trim());
|
||||
});
|
||||
|
||||
return queryStr;
|
||||
return urlSearchParams.toString();
|
||||
};
|
||||
|
||||
export const getResTitleTypeHeading = (
|
||||
|
Reference in New Issue
Block a user