385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||
import { fetchApi } from '@libs/fetch';
|
||
import { Plugin } from '@/types/plugin';
|
||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||
|
||
class MangaTR implements Plugin.PluginBase {
|
||
id = 'mangatr';
|
||
name = 'MangaTR';
|
||
icon = 'src/tr/mangatr/icon.png';
|
||
site = 'https://manga-tr.com/';
|
||
version = '1.0.1';
|
||
|
||
opts = {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
'x-requested-with': 'XMLHttpRequest',
|
||
},
|
||
};
|
||
|
||
/**
|
||
* @param novelPath
|
||
* @returns novel metadata and its first page
|
||
*/
|
||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||
const body = await fetchApi(this.site + novelPath).then(r => r.text());
|
||
const loadedCheerio = parseHTML(body);
|
||
|
||
const novel: Plugin.SourceNovel = {
|
||
path: novelPath,
|
||
name: loadedCheerio('#tables').text(),
|
||
cover: loadedCheerio(
|
||
'#myCarousel > div.container > div.col-lg-4.col-sm-4 > img',
|
||
).attr('src'),
|
||
status: loadedCheerio(
|
||
'#tab1 > table:nth-child(2) > tbody > tr:nth-child(2) > td:nth-last-child(2) > a',
|
||
).text(),
|
||
chapters: [],
|
||
author: loadedCheerio(
|
||
'#tab1 > table:nth-child(3) > tbody > tr:nth-child(2) > td:nth-child(1) > a',
|
||
)
|
||
.map((i, el) => loadedCheerio(el).text())
|
||
.get()
|
||
.join(','),
|
||
artist: loadedCheerio(
|
||
'#tab1 > table:nth-child(3) > tbody > tr:nth-child(2) > td:nth-child(2) > a',
|
||
)
|
||
.map((i, el) => loadedCheerio(el).text())
|
||
.get()
|
||
.join(','),
|
||
genres: loadedCheerio(
|
||
'#tab1 > table:nth-child(3) > tbody > tr:nth-child(2) > td:nth-child(3) > a',
|
||
)
|
||
.map((i, el) => loadedCheerio(el).text())
|
||
.get()
|
||
.join(','),
|
||
};
|
||
|
||
const summary = loadedCheerio('#tab1 > div.well');
|
||
// remove h3 and div from children
|
||
summary.children().remove('h3, div');
|
||
novel.summary = summary.text().trim();
|
||
|
||
const chapters: Plugin.ChapterItem[] = [];
|
||
|
||
// path = manga-the-last-adventurer.html
|
||
// title = the-last-adventurer
|
||
const title = novelPath.split('.html')[0].slice(6);
|
||
|
||
const url = `${this.site}cek/fetch_pages_manga.php?manga_cek=${title}`;
|
||
|
||
// make post request to url with form data page=1
|
||
const response = await fetchApi(url, {
|
||
...this.opts,
|
||
body: 'page=1',
|
||
});
|
||
|
||
const page1 = parseHTML(await response.text());
|
||
|
||
const firstPage = 1;
|
||
const lastPage = parseInt(
|
||
page1('a[title="Last"]').first().attr('data-page') ?? '1',
|
||
);
|
||
|
||
let pageDatas = await Promise.all(
|
||
Array.from({ length: lastPage - firstPage }, (_, i) => {
|
||
return fetchApi(url, {
|
||
...this.opts,
|
||
body: `page=${firstPage + i + 1}`,
|
||
}).then(r => r.text());
|
||
}),
|
||
).then(pages => pages.map(p => parseHTML(p)));
|
||
|
||
pageDatas = [page1, ...pageDatas];
|
||
|
||
// Most titles have the year number in them (e.g. (2021)) and chapter titles do not, so remove
|
||
const novelTitle = novel.name
|
||
.toLocaleLowerCase()
|
||
.replace(/\([0-9]+\)/g, '')
|
||
.trim();
|
||
|
||
// Go through each page and get the chapters
|
||
for (const page of pageDatas) {
|
||
// For each chapter
|
||
page('body > ul > table > tbody > tr').each((_, el) => {
|
||
const chap = page(el);
|
||
|
||
const chapTitle1 = chap.find('td:nth-child(1) > a').text();
|
||
const updatedChapTitle1 = chapTitle1
|
||
.toLocaleLowerCase()
|
||
.replace(novelTitle, 'Ch')
|
||
.trim();
|
||
const chapTitle2 = chap.find('td:nth-child(1) > div').text();
|
||
|
||
const chapPath = chap.find('td:nth-child(1) > a').attr('href') ?? '';
|
||
|
||
if (chapPath === '') return;
|
||
|
||
chapters.push({
|
||
name:
|
||
chapTitle2 !== ''
|
||
? `${updatedChapTitle1}: ${chapTitle2}`
|
||
: updatedChapTitle1,
|
||
path: chapPath,
|
||
chapterNumber: parseFloat(chapTitle1.replace(/[^0-9.]/g, '')),
|
||
});
|
||
});
|
||
}
|
||
|
||
if (chapters.length > 0) {
|
||
novel.chapters = chapters.reverse();
|
||
}
|
||
return novel;
|
||
}
|
||
|
||
popularNovels(
|
||
pageNo: number,
|
||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions<Filters>,
|
||
): Promise<Plugin.NovelItem[]> {
|
||
const params = new URLSearchParams();
|
||
params.append('page', pageNo.toString());
|
||
if (showLatestNovels == true) {
|
||
params.append('sort', 'last_update');
|
||
params.append('sort_type', 'DESC');
|
||
} else {
|
||
params.append('durum', filters.status.value.toString()); // status
|
||
params.append('ceviri', ''); // translation status
|
||
params.append('yas', filters.age.value.toString()); // age
|
||
params.append('icerik', '2'); // content type -> always novel (2)
|
||
params.append('tur', filters.genre.value.toString()); // genre (only 1)
|
||
params.append('sort', filters.sort.value.toString());
|
||
params.append('sort_type', filters.sort_type.value.toString());
|
||
}
|
||
|
||
const url = `${this.site}manga-list-sayfala.html?${params.toString()}`;
|
||
|
||
const parseNovels = (loadedCheerio: CheerioAPI): Plugin.NovelItem[] => {
|
||
return loadedCheerio(
|
||
'#myCarousel > div.container > div:nth-child(3) > div.col-lg-9.col-md-8 > div.col-md-12',
|
||
)
|
||
.map((_, el) => {
|
||
const novel = loadedCheerio(el);
|
||
return {
|
||
name: novel.find('#tables').text(),
|
||
path: novel.find('#tables > a').attr('href') ?? '',
|
||
cover: novel.find('img.img-thumb').first().attr('src') ?? '',
|
||
};
|
||
})
|
||
.toArray();
|
||
};
|
||
|
||
return fetchApi(url)
|
||
.then(r => r.text())
|
||
.then(body => {
|
||
const loadedCheerio = parseHTML(body);
|
||
return parseNovels(loadedCheerio);
|
||
});
|
||
}
|
||
|
||
async parseChapter(chapterPath: string): Promise<string> {
|
||
const body = await fetchApi(this.site + chapterPath).then(r => r.text());
|
||
const loadedCheerio = parseHTML(body);
|
||
|
||
const content = loadedCheerio('#well');
|
||
|
||
return content.html() ?? '';
|
||
}
|
||
|
||
async searchNovels(
|
||
searchTerm: string,
|
||
pageNo: number,
|
||
): Promise<Plugin.NovelItem[]> {
|
||
// Not paginated, so we should paginate results ourselves to prevent overloading
|
||
const ITEMS_PER_PAGE = 50;
|
||
|
||
const params = new URLSearchParams();
|
||
params.append('icerik', searchTerm);
|
||
|
||
const url = `${this.site}arama.html?${params.toString()}`;
|
||
|
||
const parseNovels = async (
|
||
loadedCheerio: CheerioAPI,
|
||
): Promise<Plugin.NovelItem[]> => {
|
||
const novels: Plugin.NovelItem[] = [];
|
||
|
||
let curr = 0;
|
||
|
||
for (const el of loadedCheerio('div.char > a + span').toArray()) {
|
||
// Done!
|
||
if (novels.length === ITEMS_PER_PAGE) break;
|
||
// Skip if not a novel
|
||
if (
|
||
loadedCheerio(el).text().trim().toLowerCase() != 'novel' &&
|
||
loadedCheerio(el).next().text().trim().toLowerCase() != 'novel'
|
||
) {
|
||
continue;
|
||
}
|
||
// Skip for pagination
|
||
if ((pageNo - 1) * ITEMS_PER_PAGE > curr) {
|
||
curr++;
|
||
continue;
|
||
}
|
||
|
||
const novelCheerio = loadedCheerio(el).prev();
|
||
|
||
const mangaSlug = novelCheerio.attr('manga-slug') ?? '';
|
||
|
||
novels.push({
|
||
name: novelCheerio.text(),
|
||
path: novelCheerio.attr('href') ?? '',
|
||
cover: mangaSlug, // NOTE: This slug gets replaced with the actual cover image later (see below)
|
||
});
|
||
}
|
||
|
||
// Get all cover images in parallel
|
||
return await Promise.all(
|
||
novels.map(async novel => {
|
||
const url = `${this.site}app/manga/controllers/cont.pop.php`;
|
||
const response = await fetchApi(url, {
|
||
...this.opts,
|
||
body: `slug=${novel.cover}`,
|
||
});
|
||
const body = await response.text();
|
||
const imgCheerio = parseHTML(body);
|
||
const img = imgCheerio('img').first().attr('src');
|
||
|
||
novel.cover = img;
|
||
return novel;
|
||
}),
|
||
);
|
||
};
|
||
|
||
return fetchApi(url)
|
||
.then(r => r.text())
|
||
.then(body => {
|
||
const loadedCheerio = parseHTML(body);
|
||
return parseNovels(loadedCheerio);
|
||
});
|
||
}
|
||
|
||
// resolveUrl(path: string, isNovel?: boolean): string {
|
||
// return this.site + path;
|
||
// }
|
||
|
||
filters = {
|
||
sort: {
|
||
value: 'views',
|
||
label: 'Sırala',
|
||
options: [
|
||
{ label: 'Adı', value: 'name' },
|
||
{ label: 'Popülarite', value: 'views' },
|
||
{ label: 'Son Güncelleme', value: 'last_update' },
|
||
],
|
||
type: FilterTypes.Picker,
|
||
},
|
||
sort_type: {
|
||
value: 'DESC',
|
||
label: 'Sırala Türü',
|
||
options: [
|
||
{ label: 'ASC', value: 'ASC' },
|
||
{ label: 'DESC', value: 'DESC' },
|
||
],
|
||
type: FilterTypes.Picker,
|
||
},
|
||
status: {
|
||
value: '',
|
||
label: 'Durum',
|
||
options: [
|
||
{ label: 'Hepsi', value: '' },
|
||
{ label: 'Yayınlanması Tamamlanan', value: '1' },
|
||
{ label: 'Devam Eden', value: '2' },
|
||
],
|
||
type: FilterTypes.Picker,
|
||
},
|
||
translation: {
|
||
value: '',
|
||
label: 'Çeviri Durumu',
|
||
options: [
|
||
{ label: 'Hepsi', value: '' },
|
||
{ label: 'Çevirisi Tamamlanan', value: '1' },
|
||
{ label: 'Devam Eden', value: '4' },
|
||
{ label: 'Bırakılan', value: '2' },
|
||
{ label: 'Olmayan', value: '3' },
|
||
],
|
||
type: FilterTypes.Picker,
|
||
},
|
||
age: {
|
||
value: '',
|
||
label: 'Yas',
|
||
options: [
|
||
{ label: 'Hepsi', value: '' },
|
||
{ label: '+16', value: '16' },
|
||
{ label: '+18', value: '18' },
|
||
],
|
||
type: FilterTypes.Picker,
|
||
},
|
||
genre: {
|
||
value: '',
|
||
label: 'Tür',
|
||
options: [
|
||
{ label: 'Hepsi', value: '' },
|
||
{ label: '4 Koma', value: '4_Koma' },
|
||
{ label: 'Action', value: 'Action' },
|
||
{ label: 'Adventure', value: 'Adventure' },
|
||
{ label: 'Aliens', value: 'Aliens' },
|
||
{ label: 'Art', value: 'Art' },
|
||
{ label: 'Biography', value: 'Biography' },
|
||
{ label: 'Bishoujo', value: 'Bishoujo' },
|
||
{ label: 'Bishounen', value: 'Bishounen' },
|
||
{ label: 'Comedy', value: 'Comedy' },
|
||
{ label: 'Crime', value: 'Crime' },
|
||
{ label: 'Demons', value: 'Demons' },
|
||
{ label: 'Doujinshi', value: 'Doujinshi' },
|
||
{ label: 'Drama', value: 'Drama' },
|
||
{ label: 'Ecchi', value: 'Ecchi' },
|
||
{ label: 'Fantasy', value: 'Fantasy' },
|
||
{ label: 'Gore', value: 'Gore' },
|
||
{ label: 'Harem', value: 'Harem' },
|
||
{ label: 'History', value: 'History' },
|
||
{ label: 'Horror', value: 'Horror' },
|
||
{ label: 'Isekai', value: 'Isekai' },
|
||
{ label: 'Josei', value: 'Josei' },
|
||
{ label: 'Magic', value: 'Magic' },
|
||
{ label: 'Manhua', value: 'Manhua' },
|
||
{ label: 'Manhwa', value: 'Manhwa' },
|
||
{ label: 'Martial', value: 'Martial' },
|
||
{ label: 'Mecha', value: 'Mecha' },
|
||
{ label: 'Military', value: 'Military' },
|
||
{ label: 'Miscellaneous', value: 'Miscellaneous' },
|
||
{ label: 'Monster', value: 'Monster' },
|
||
{ label: 'Musical', value: 'Musical' },
|
||
{ label: 'Mystery', value: 'Mystery' },
|
||
{ label: 'Novel', value: 'Novel' },
|
||
{ label: 'Nudity', value: 'Nudity' },
|
||
{ label: 'One Shot', value: 'One_Shot' },
|
||
{ label: 'Romance', value: 'Romance' },
|
||
{ label: 'Psychological', value: 'Psychological' },
|
||
{ label: 'Seinen', value: 'Seinen' },
|
||
{ label: 'School', value: 'School' },
|
||
{ label: 'Sci fi', value: 'Sci_fi' },
|
||
{ label: 'Short', value: 'Short' },
|
||
{ label: 'Shoujo', value: 'Shoujo' },
|
||
{ label: 'Shoujo Ai', value: 'Shoujo_Ai' },
|
||
{ label: 'Shounen', value: 'Shounen' },
|
||
{ label: 'Shounen Ai', value: 'Shounen_Ai' },
|
||
{ label: 'Slice of life', value: 'Slice of life' },
|
||
{ label: 'Supernatural', value: 'Supernatural' },
|
||
{ label: 'Space', value: 'Space' },
|
||
{ label: 'Sports', value: 'Sports' },
|
||
{ label: 'Thriller', value: 'Thriller' },
|
||
{ label: 'Tragedy', value: 'Tragedy' },
|
||
{ label: 'Türkçe Novel', value: 'Türkçe Novel' },
|
||
{ label: 'Vampires', value: 'Vampires' },
|
||
{ label: 'Violence', value: 'Violence' },
|
||
{ label: 'War', value: 'War' },
|
||
{ label: 'Webtoon', value: 'Webtoon' },
|
||
{ label: 'Western', value: 'Western' },
|
||
],
|
||
type: FilterTypes.Picker,
|
||
},
|
||
} satisfies Filters;
|
||
}
|
||
|
||
export default new MangaTR();
|