From d5397fed69a4232a95aaa5a1e66387c337d83d55 Mon Sep 17 00:00:00 2001 From: RikCost <69048938+RikCost@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:48:28 +0300 Subject: [PATCH 1/2] feat(russian/ranobelife): add Ranobe.life DataLife Engine site: catalog with sort and genre filters, search, novel details and the full chapter list from the page's embedded JSON, chapter text with illustrations. The site sends visitors through its own browser check; until it is passed in the WebView the plugin asks to open it. Co-Authored-By: Claude Opus 5.5 --- plugins/russian/ranobelife.ts | 278 +++++++++++++++++++++++ public/static/src/ru/ranobelife/icon.png | Bin 0 -> 6619 bytes 2 files changed, 278 insertions(+) create mode 100644 plugins/russian/ranobelife.ts create mode 100644 public/static/src/ru/ranobelife/icon.png diff --git a/plugins/russian/ranobelife.ts b/plugins/russian/ranobelife.ts new file mode 100644 index 000000000..0aa3590e5 --- /dev/null +++ b/plugins/russian/ranobelife.ts @@ -0,0 +1,278 @@ +import { Plugin } from '@/types/plugin'; +import { FilterTypes, Filters } from '@libs/filterInputs'; +import { fetchApi } from '@libs/fetch'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; +import { load as parseHTML } from 'cheerio'; + +class RanobeLife implements Plugin.PluginBase { + id = 'ranobelife'; + name = 'Ranobe.life'; + site = 'https://ranobe.life'; + version = '1.0.0'; + icon = 'src/ru/ranobelife/icon.png'; + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + const genre = filters?.genre?.value; + const url = + this.site + + '/catalog/' + + (genre ? 'g=' + genre + '/' : '') + + (pageNo > 1 ? 'page/' + pageNo + '/' : ''); + const sort = showLatestNovels ? 'editdate' : filters?.sort?.value; + + // DataLife Engine applies a sort sent with the request + const html = await this.fetchPage(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + dlenewssortby: sort || 'rating', + dledirection: sort === 'title' ? 'asc' : 'desc', + set_new_sort: 'dle_sort_xfilter', + set_direction_sort: 'dle_direction_xfilter', + }).toString(), + }); + return this.parseNovelList(html); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const url = + this.site + + '/search/' + + encodeURIComponent(searchTerm) + + '/' + + (pageNo > 1 ? 'page/' + pageNo + '/' : ''); + return this.parseNovelList(await this.fetchPage(url)); + } + + parseNovelList(html: string): Plugin.NovelItem[] { + const $ = parseHTML(html); + const novels: Plugin.NovelItem[] = []; + + $('div.short').each((index, element) => { + const link = $(element).find('h3 a').first(); + const href = link.attr('href'); + if (!href) return; + const image = $(element).find('img').first(); + const cover = image.attr('data-src') || image.attr('src'); + + novels.push({ + name: link.text().trim(), + path: href.replace(this.site, ''), + cover: + cover && !cover.startsWith('data:') + ? this.absolute(cover) + : defaultCover, + }); + }); + + return novels; + } + + async parseNovel(novelPath: string): Promise { + const html = await this.fetchPage(this.site + novelPath); + const $ = parseHTML(html); + + const info: Record = {}; + $('.page__list li').each((index, element) => { + const title = $(element).children('div').first().text().trim(); + $(element).children('div').first().remove(); + info[title] = $(element).text().trim(); + }); + + const cover = $('.page__poster img').first().attr('src'); + const status = info['Статус:'] || ''; + const novel: Plugin.SourceNovel = { + path: novelPath, + name: $('h1').first().text().trim(), + cover: cover ? this.absolute(cover) : defaultCover, + summary: this.htmlToText($('.page__text').first().html() || ''), + status: status.includes('Заверш') + ? NovelStatus.Completed + : status.includes('Онгоинг') + ? NovelStatus.Ongoing + : NovelStatus.Unknown, + genres: this.readGenres($), + }; + + // the chapter list is embedded as JSON for the page's Vue component + const json = html.match(/window\.__DATA__ = (\{[\s\S]*?\});?\s*<\/script>/); + const data: PageData = json ? JSON.parse(json[1]) : { chapters: [] }; + novel.chapters = (data.chapters || []) + .slice() + .sort((a, b) => a.posi - b.posi) + .map(chapter => ({ + name: chapter.title, + path: '/reader/' + data.news_id + '/' + chapter.id, + releaseTime: this.parseDate(chapter.date), + chapterNumber: chapter.posi, + })); + + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const $ = parseHTML(await this.fetchPage(this.site + chapterPath)); + const text = $('div.rl-text').first(); + if (!text.length) throw new Error('Текст главы недоступен'); + + text.find('img').each((index, element) => { + const src = $(element).attr('data-src') || $(element).attr('src'); + if (src) $(element).attr('src', this.absolute(src)); + }); + return text.html() || ''; + } + + // the site answers with a redirect to its own browser check (/_c?t=...) + // until it has been passed once in the WebView + async fetchPage(url: string, init?: Parameters[1]) { + const res = await fetchApi(url, init); + const body = await res.text(); + if (res.url.includes('/_c?') || body.includes('var targetUrl')) { + throw new Error('Пройдите проверку: откройте сайт в WebView'); + } + if (!res.ok) { + throw new Error('Не удалось открыть страницу (' + res.status + ')'); + } + return body; + } + + readGenres($: ReturnType): string | undefined { + for (const element of $('script[type="application/ld+json"]').toArray()) { + try { + const graph = JSON.parse($(element).text())['@graph'] || []; + const series = graph.find((item: { genre?: string[] }) => + Array.isArray(item.genre), + ); + if (series) return series.genre.join(','); + } catch { + // not the structured data block we need + } + } + return undefined; + } + + parseDate(date: string): string | null { + // "10.08.2026" + const [, day, month, year] = + date.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/) || []; + if (!day) return null; + const pad = (part: string) => ('0' + part).slice(-2); + return year + '-' + pad(month) + '-' + pad(day); + } + + // the app shows summaries as plain text + htmlToText(html: string): string | undefined { + const $ = parseHTML(html); + $('br').replaceWith('\n'); + $('p, div, li').after('\n\n'); + return ( + $.root() + .text() + .replace(/[ \t]*\n[ \t]*/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() || undefined + ); + } + + absolute = (url: string) => + url.startsWith('http') + ? url + : this.site + (url.startsWith('/') ? '' : '/') + url; + + resolveUrl = (path: string) => this.site + path; + + filters = { + sort: { + label: 'Сортировка', + value: 'rating', + options: [ + { label: 'По популярности', value: 'rating' }, + { label: 'По посещаемости', value: 'news_read' }, + { label: 'По дате изменения', value: 'editdate' }, + { label: 'По дате добавления', value: 'date' }, + { label: 'По комментариям', value: 'comm_num' }, + { label: 'По алфавиту', value: 'title' }, + ], + type: FilterTypes.Picker, + }, + genre: { + label: 'Жанр', + value: '', + options: [ + { label: 'Любой', value: '' }, + { label: 'Арт', value: '279' }, + { label: 'Безумие', value: '273' }, + { label: 'Боевик', value: '241' }, + { label: 'Боевые искусства', value: '252' }, + { label: 'Вампиры', value: '264' }, + { label: 'Военное', value: '251' }, + { label: 'Гарем', value: '242' }, + { label: 'Героическое фэнтези', value: '265' }, + { label: 'Демоны', value: '256' }, + { label: 'Детектив', value: '277' }, + { label: 'Детское', value: '289' }, + { label: 'Дзёсэй', value: '270' }, + { label: 'Драма', value: '243' }, + { label: 'Игра', value: '269' }, + { label: 'Исекай', value: '255' }, + { label: 'История', value: '274' }, + { label: 'Киберпанк', value: '284' }, + { label: 'Кодомо', value: '288' }, + { label: 'Комедия', value: '244' }, + { label: 'Космос', value: '271' }, + { label: 'Магия', value: '250' }, + { label: 'Махо-сёдзё', value: '285' }, + { label: 'Машины', value: '280' }, + { label: 'Меха', value: '278' }, + { label: 'Мистика', value: '261' }, + { label: 'Музыка', value: '281' }, + { label: 'Научная фантастика', value: '253' }, + { label: 'Омегаверс', value: '287' }, + { label: 'Пародия', value: '283' }, + { label: 'Повседневность', value: '257' }, + { label: 'Полиция', value: '282' }, + { label: 'Постапокалиптика', value: '258' }, + { label: 'Приключения', value: '254' }, + { label: 'Психология', value: '267' }, + { label: 'Романтика', value: '245' }, + { label: 'Самурайский боевик', value: '286' }, + { label: 'Сверхъестественное', value: '246' }, + { label: 'Спорт', value: '272' }, + { label: 'Супер сила', value: '276' }, + { label: 'Сэйнэн', value: '247' }, + { label: 'Сёдзё', value: '275' }, + { label: 'Сёнэн', value: '259' }, + { label: 'Трагедия', value: '268' }, + { label: 'Триллер', value: '266' }, + { label: 'Ужасы', value: '262' }, + { label: 'Фантастика', value: '263' }, + { label: 'Фэнтези', value: '248' }, + { label: 'Школа', value: '260' }, + { label: 'Этти', value: '249' }, + ], + type: FilterTypes.Picker, + }, + } satisfies Filters; +} + +type PageData = { + news_id?: number; + chapters: { + id: number; + posi: number; + title: string; + date: string; + }[]; +}; + +export default new RanobeLife(); diff --git a/public/static/src/ru/ranobelife/icon.png b/public/static/src/ru/ranobelife/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6ba0dd0605c4ca0203dc4c80081cc8fa89a9d4ab GIT binary patch literal 6619 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 z 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 Z|9>HHG91 Date: Sun, 25 Oct 2026 00:03:40 +0300 Subject: [PATCH 2/2] fix(russian/ranobelife): fail on a missing chapter list, keep absolute URLs - throw when the page has no embedded chapter data instead of showing the novel without chapters; a real empty list is still accepted - take the novel id from the path when news_id is missing - resolveUrl keeps absolute URLs Co-Authored-By: Claude Opus 5.5 --- plugins/russian/ranobelife.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/russian/ranobelife.ts b/plugins/russian/ranobelife.ts index 0aa3590e5..e2f47abd8 100644 --- a/plugins/russian/ranobelife.ts +++ b/plugins/russian/ranobelife.ts @@ -105,14 +105,20 @@ class RanobeLife implements Plugin.PluginBase { }; // the chapter list is embedded as JSON for the page's Vue component + // A missing block means the markup changed: fail instead of showing a + // novel without chapters (a real empty list is still accepted). const json = html.match(/window\.__DATA__ = (\{[\s\S]*?\});?\s*<\/script>/); - const data: PageData = json ? JSON.parse(json[1]) : { chapters: [] }; - novel.chapters = (data.chapters || []) + const data: PageData | undefined = json ? JSON.parse(json[1]) : undefined; + if (!data || !Array.isArray(data.chapters)) { + throw new Error('Не удалось загрузить список глав'); + } + const novelId = data.news_id || novelPath.match(/^\/(\d+)-/)?.[1]; + novel.chapters = data.chapters .slice() .sort((a, b) => a.posi - b.posi) .map(chapter => ({ name: chapter.title, - path: '/reader/' + data.news_id + '/' + chapter.id, + path: '/reader/' + novelId + '/' + chapter.id, releaseTime: this.parseDate(chapter.date), chapterNumber: chapter.posi, })); @@ -189,7 +195,7 @@ class RanobeLife implements Plugin.PluginBase { ? url : this.site + (url.startsWith('/') ? '' : '/') + url; - resolveUrl = (path: string) => this.site + path; + resolveUrl = (path: string) => this.absolute(path); filters = { sort: {