Files
lnreader-plugins/plugins/multisrc/ranobes/template.ts
T
2026-05-18 10:39:07 +05:00

472 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Parser } from 'htmlparser2';
import { fetchApi, FetchInit } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { NovelStatus } from '@libs/novelStatus';
type RanobesOptions = {
lang?: string;
path: string;
};
export type RanobesMetadata = {
id: string;
sourceSite: string;
sourceName: string;
options?: RanobesOptions;
};
export class RanobesPlugin implements Plugin.PluginBase {
id: string;
name: string;
icon: string;
site: string;
version: string;
options: RanobesOptions;
constructor(metadata: RanobesMetadata) {
this.id = metadata.id;
this.name = metadata.sourceName;
this.icon = 'multisrc/ranobes/ranobes/icon.png';
this.site = metadata.sourceSite;
this.version = '2.0.2';
this.options = metadata.options as RanobesOptions;
}
async safeFecth(url: string, init?: FetchInit): Promise<string> {
const r = await fetchApi(url, init);
if (!r.ok)
throw new Error(
'Could not reach site (' + r.status + ') try to open in webview.',
);
const data = await r.text();
const title = data.match(/<title>(.*?)<\/title>/)?.[1]?.trim();
if (
title &&
(title == 'Bot Verification' ||
title == 'You are being redirected...' ||
title == 'Un instant...' ||
title == 'Just a moment...' ||
title == 'Redirecting...')
)
throw new Error('Captcha error, please open in webview');
return data;
}
parseNovels(html: string) {
const novels: Plugin.NovelItem[] = [];
let tempNovel = {} as Plugin.NovelItem;
tempNovel.name = '';
const baseUrl = this.site;
let isParsingNovel = false;
let isTitleTag = false;
let isNovelName = false;
const parser = new Parser({
onopentag(name, attribs) {
if (attribs['class']?.includes('short-cont')) {
isParsingNovel = true;
}
if (isParsingNovel) {
if (name === 'h2' && attribs['class']?.includes('title')) {
isTitleTag = true;
}
if (isTitleTag && name === 'a') {
tempNovel.path = attribs['href'].slice(baseUrl.length);
isNovelName = true;
}
if (name === 'figure') {
tempNovel.cover = attribs['style'].replace(
/.*url\((.*?)\)./g,
'$1',
);
}
if (tempNovel.path && tempNovel.cover) {
novels.push(tempNovel);
tempNovel = {} as Plugin.NovelItem;
tempNovel.name = '';
}
}
},
ontext(data) {
if (isNovelName) {
tempNovel.name += data;
}
},
onclosetag(name) {
if (name === 'h2') {
isNovelName = false;
isTitleTag = false;
}
if (name === 'figure') {
isParsingNovel = false;
}
},
});
parser.write(html);
parser.end();
return novels;
}
parseChapters(data: { chapters: ChapterEntry[] }) {
const chapter: Plugin.ChapterItem[] = [];
data.chapters.map((item: ChapterEntry) => {
chapter.push({
name: item.title,
releaseTime: new Date(item.date).toISOString(),
path: item.link.slice(this.site.length),
});
});
return chapter.reverse();
}
parseDate = (date: string) => {
const now = new Date();
if (!date) return now.toISOString();
if (this.id === 'ranobes-ru') {
if (date.includes(' в ')) return date.replace(' в ', ' г., ');
const [when, time] = date.split(', ');
if (!time) return now.toISOString();
const [h, m] = time.split(':');
switch (when) {
case 'Сегодня':
now.setHours(parseInt(h, 10));
now.setMinutes(parseInt(m, 10));
break;
case 'Вчера':
now.setDate(now.getDate() - 1);
now.setHours(parseInt(h, 10));
now.setMinutes(parseInt(m, 10));
break;
default:
return now.toISOString();
}
} else {
const [num, xz, ago] = date.split(' ');
if (ago !== 'ago') return now.toISOString();
switch (xz) {
case 'minutes':
now.setMinutes(parseInt(num, 10));
break;
case 'hour':
case 'hours':
now.setHours(parseInt(num, 10));
break;
case 'day':
case 'days':
now.setDate(now.getDate() - parseInt(num, 10));
break;
case 'month':
case 'months':
now.setMonth(now.getMonth() - parseInt(num, 10));
break;
case 'year':
case 'years':
now.setFullYear(now.getFullYear() - parseInt(num, 10));
break;
default:
return now.toISOString();
}
}
return now.toISOString();
};
async popularNovels(page: number): Promise<Plugin.NovelItem[]> {
const link = `${this.site}/${this.options.path}/page/${page}/`;
const body = await this.safeFecth(link);
return this.parseNovels(body);
}
async parseNovel(
novelPath: string,
): Promise<Plugin.SourceNovel & { totalPages: number }> {
const baseUrl = this.site;
const html = await this.safeFecth(baseUrl + novelPath);
const novel: Plugin.SourceNovel & { totalPages: number } = {
path: novelPath,
name: '',
summary: '',
chapters: [],
totalPages: 1,
};
let isCover = false;
let isAuthor = false;
let isSummary = false;
let isStatus = false;
let isStatusText = false;
let isGenres = false;
let isGenresText = false;
let isMaxChapters = false;
let isChapter = false;
let isChapterTitle = false;
let isChapterDate = false;
const genreArray: string[] = [];
const chapters: Plugin.ChapterItem[] = [];
let tempchapter: Plugin.ChapterItem = {};
let maxChapters = 0;
const fixDate = this.parseDate;
const parser = new Parser({
onopentag(name, attribs) {
if (attribs['class'] === 'poster') {
isCover = true;
}
if (isCover && name === 'img') {
novel.name = attribs['alt'];
novel.cover = baseUrl + attribs['src'];
}
if (
(name === 'div' &&
attribs['class'] === 'moreless cont-text showcont-h') ||
(attribs['class'] === 'cont-text showcont-h' &&
attribs['itemprop'] === 'description')
) {
isSummary = true;
}
if (
name === 'li' &&
attribs['title'] &&
(attribs['title'].includes('Original status') ||
attribs['title'].includes('Статус оригинала'))
) {
isStatus = true;
}
if (name === 'a' && attribs['rel'] === 'chapter') {
isChapter = true;
tempchapter.path = attribs['href'].replace(baseUrl, '');
}
if (
isChapter &&
name === 'span' &&
attribs['class'] === 'title ellipses'
) {
isChapterTitle = true;
}
if (isChapter && name === 'span' && attribs['class'] === 'grey') {
isChapterDate = true;
}
if (
name === 'li' &&
(attribs['title'] ==
'Glossary + illustrations + division of chapters, etc.' ||
attribs['title'] ===
'Глоссарий + иллюстраций + разделение глав и т.д.')
) {
isMaxChapters = true;
}
},
onopentagname(name) {
if (isSummary && name === 'br') {
novel.summary += '\n';
}
if (isStatus && name === 'a') {
isStatusText = true;
}
if (isGenres && name === 'a') {
isGenresText = true;
}
},
onattribute(name, value) {
if (name === 'itemprop' && value === 'creator') {
isAuthor = true;
}
if (name === 'id' && value === 'mc-fs-genre') {
isGenres = true;
}
},
ontext(data) {
if (isAuthor) {
novel.author = data;
}
if (isSummary) {
novel.summary += data.trim();
}
if (isStatusText) {
novel.status =
data === 'Ongoing' || data == 'В процессе'
? NovelStatus.Ongoing
: NovelStatus.Completed;
}
if (isGenresText) {
genreArray.push(data);
}
if (isMaxChapters) {
const isNumber = data.replace(/\D/g, '');
if (isNumber) {
maxChapters = parseInt(isNumber, 10);
}
}
if (isChapter) {
if (isChapterTitle) tempchapter.name = data.trim();
if (isChapterDate) tempchapter.releaseTime = fixDate(data.trim());
}
},
onclosetag(name) {
if (name === 'a') {
isCover = false;
isAuthor = false;
isStatusText = false;
isGenresText = false;
isStatus = false;
}
if (name === 'div') {
isSummary = false;
isGenres = false;
}
if (name === 'li') {
isMaxChapters = false;
}
if (name === 'a') {
isChapter = false;
if (tempchapter.name) {
chapters.push({ ...tempchapter, page: '1' });
tempchapter = {};
}
}
if (name === 'span') {
if (isChapterTitle) isChapterTitle = false;
if (isChapterDate) isChapterDate = false;
}
},
});
parser.write(html);
parser.end();
novel.genres = genreArray.join(', ');
novel.totalPages = Math.ceil((maxChapters || 1) / 25);
novel.chapters = chapters;
if (novel.chapters[0].path) {
novel.latestChapter = novel.chapters[0];
}
return novel;
}
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
const pagePath =
this.id == 'ranobes'
? novelPath.split('-')[0]
: '/' + novelPath.split('-').slice(1).join('-').split('.')[0];
const firstUrl =
this.site + '/chapters' + pagePath.replace(this.options.path + '/', '');
const pageBody = await this.safeFecth(firstUrl + '/page/' + page);
const baseUrl = this.site;
let isScript = false;
let isChapter = false;
let isChapterInfo = false;
let isChapterDate = false;
let chapters: Plugin.ChapterItem[] = [];
let tempchapter: Plugin.ChapterItem = {};
const fixDate = this.parseDate;
let dataJson: {
pages_count: string;
chapters: ChapterEntry[];
} = { pages_count: '', chapters: [] };
const parser = new Parser({
onopentag(name, attribs) {
if (name === 'div' && attribs['class'] === 'cat_block cat_line') {
isChapter = true;
}
if (isChapter && name === 'a' && attribs['title'] && attribs['href']) {
tempchapter.name = attribs['title'];
tempchapter.path = attribs['href'].replace(baseUrl, '');
}
if (name === 'span' && attribs['class'] === 'grey small') {
isChapterInfo = true;
}
if (name === 'small' && isChapterInfo) {
isChapterDate = true;
}
},
ontext(data) {
if (isChapterDate) tempchapter.releaseTime = fixDate(data.trim());
if (isScript) {
if (data.includes('window.__DATA__ =')) {
dataJson = JSON.parse(data.replace('window.__DATA__ =', ''));
}
}
},
onclosetag(name) {
if (name === 'a' && tempchapter.name) {
chapters.push(tempchapter);
tempchapter = {};
}
if (name === 'div') {
isChapter = false;
}
if (name === 'span') {
isChapterInfo = false;
}
if (name === 'small') {
isChapterDate = false;
}
if (name === 'main') {
isScript = true;
}
if (name === 'script') {
isScript = false;
}
},
});
parser.write(pageBody);
parser.end();
if (dataJson.chapters?.length) {
chapters = this.parseChapters(dataJson);
}
return {
chapters,
};
}
async parseChapter(chapterPath: string): Promise<string> {
const html = await this.safeFecth(this.site + chapterPath);
const indexA = html.indexOf('<div class="text" id="arrticle">');
const indexB = html.indexOf('<div class="category grey ellipses">', indexA);
const chapterText = html.substring(indexA, indexB);
return chapterText;
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
let html;
if (this.id === 'ranobes-ru') {
html = await this.safeFecth(this.site + '/index.php?do=search', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Referer: this.site + '/',
},
method: 'POST',
body: new URLSearchParams({
do: 'search',
subaction: 'search',
search_start: page.toString(),
story: searchTerm,
}).toString(),
});
} else {
const link = `${this.site}/search/${searchTerm}/page/${page}`;
html = await this.safeFecth(link);
}
return this.parseNovels(html);
}
}
type ChapterEntry = {
id: number;
title: string;
date: string;
link: string;
};