From 2575ec6f9a0c3a3ec67518ea3dacb6db1e3126c1 Mon Sep 17 00:00:00 2001 From: RikCost <69048938+RikCost@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:32:27 +0300 Subject: [PATCH 1/2] feat(russian/ranobeme): add Ranobe.me Catalog with sort, genre and completed filters, search, full chapter list with release dates, chapter text and a plain-text summary. Co-Authored-By: Claude Opus 5.5 --- plugins/russian/ranobeme.ts | 243 +++++++++++++++++++++++++ public/static/src/ru/ranobeme/icon.png | Bin 0 -> 3108 bytes 2 files changed, 243 insertions(+) create mode 100644 plugins/russian/ranobeme.ts create mode 100644 public/static/src/ru/ranobeme/icon.png diff --git a/plugins/russian/ranobeme.ts b/plugins/russian/ranobeme.ts new file mode 100644 index 000000000..e47b2063e --- /dev/null +++ b/plugins/russian/ranobeme.ts @@ -0,0 +1,243 @@ +import { Plugin } from '@/types/plugin'; +import { FilterTypes, Filters } from '@libs/filterInputs'; +import { fetchApi } from '@libs/fetch'; +import { defaultCover } from '@libs/defaultCover'; +import { load as parseHTML } from 'cheerio'; + +const months: Record = { + января: 1, + февраля: 2, + марта: 3, + апреля: 4, + мая: 5, + июня: 6, + июля: 7, + августа: 8, + сентября: 9, + октября: 10, + ноября: 11, + декабря: 12, +}; + +class RanobeMe implements Plugin.PluginBase { + id = 'ranobeme'; + name = 'Ranobe.me'; + site = 'https://ranobe.me'; + version = '1.0.0'; + icon = 'src/ru/ranobeme/icon.png'; + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + let url = this.site + '/catalog'; + if (filters?.genre?.value) { + url += '/genre/' + filters.genre.value; + } else if (filters?.completed?.value) { + url += '/completed'; + } + // without a sort parameter the catalog is ordered by last update + const sort = showLatestNovels ? '' : filters?.sort?.value || ''; + url += '?' + (sort ? 'sort=' + sort + '&' : '') + 'page=' + pageNo; + + return this.parseNovelList(await this.fetchPage(url)); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const url = + this.site + + '/index.php?section=search&str=' + + encodeURIComponent(searchTerm) + + '&page=' + + pageNo; + return this.parseNovelList(await this.fetchPage(url)); + } + + parseNovelList(html: string): Plugin.NovelItem[] { + const $ = parseHTML(html); + const novels: Plugin.NovelItem[] = []; + + $('div.FicTable').each((index, element) => { + const link = $(element).find('.FicTable_Title > a[href^="/ranobe"]'); + const path = link.attr('href'); + if (!path) return; + const cover = $(element).find('.FicTable_Cover img').attr('src'); + + novels.push({ + name: link.text().trim(), + path, + cover: cover ? this.site + cover : defaultCover, + }); + }); + + return novels; + } + + async parseNovel(novelPath: string): Promise { + const $ = parseHTML(await this.fetchPage(this.site + novelPath)); + const head = $('.FicHead'); + const cover = head.find('.FicCover img'); + + const info: Record = {}; + head.find('.FicHeadRight .tr').each((index, element) => { + const title = $(element).find('.title').text().trim(); + info[title] = $(element).find('.content').text().trim(); + }); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: head.find('h1').first().text().trim(), + cover: + cover.attr('data-src') || cover.attr('src') + ? this.site + (cover.attr('data-src') || cover.attr('src')) + : defaultCover, + author: info['Автор:'] || undefined, + genres: head + .find('.FicHeadRight a[href^="/catalog/genre/"]') + .map((index, element) => $(element).text().trim()) + .get() + .join(','), + summary: this.htmlToText($('.summary_text_fic3').first().html() || ''), + }; + + const chapters: Plugin.ChapterItem[] = []; + $('ul.FicContents li').each((index, element) => { + const link = $(element).find('.FicContentsChapterName a'); + const path = link.attr('href'); + if (!path) return; + + chapters.push({ + name: link.text().trim(), + path, + // "24 апреля 2020 / 11 т.с." + releaseTime: this.parseDate($(element).find('.small_light').text()), + chapterNumber: chapters.length + 1, + }); + }); + + novel.chapters = chapters; + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const $ = parseHTML(await this.fetchPage(this.site + chapterPath)); + const chapter = $('div.chapter').first(); + if (!chapter.length) throw new Error('Текст главы недоступен'); + + chapter.find('a[name]').remove(); + chapter.find('img').each((index, element) => { + const src = $(element).attr('src'); + if (src?.startsWith('/')) $(element).attr('src', this.site + src); + }); + return chapter.html() || ''; + } + + async fetchPage(url: string): Promise { + const res = await fetchApi(url); + if (!res.ok) { + throw new Error('Не удалось открыть страницу (' + res.status + ')'); + } + return res.text(); + } + + parseDate(dateString: string): string | null { + const [, day, month, year] = + dateString.match(/(\d{1,2})\s+([а-я]+)\s+(\d{4})/) || []; + if (!day || !months[month]) return null; + const pad = (part: number | string) => ('0' + part).slice(-2); + return year + '-' + pad(months[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 + ); + } + + resolveUrl = (path: string) => this.site + path; + + filters = { + sort: { + label: 'Сортировка', + value: 'stat', + options: [ + { label: 'По обновлению', value: '' }, + { label: 'По просмотрам', value: 'stat' }, + { label: 'По читателям', value: 'readers' }, + { label: 'По рекомендациям', value: 'recommend' }, + { label: 'По размеру', value: 'size' }, + { label: 'По названию', value: 'title' }, + ], + type: FilterTypes.Picker, + }, + completed: { + label: 'Только завершённые', + value: false, + type: FilterTypes.Switch, + }, + genre: { + label: 'Жанр (без учёта «Только завершённые»)', + value: '', + options: [ + { label: 'Любой', value: '' }, + { label: '16+', value: '15' }, + { label: '18+', value: '2' }, + { label: 'Боевые искусства', value: '14' }, + { label: 'В прошлом', value: '10' }, + { label: 'Гарем', value: '9' }, + { label: 'Детектив', value: '41' }, + { label: 'Дзёсэй', value: '12' }, + { label: 'Драма', value: '5' }, + { label: 'Игра', value: '38' }, + { label: 'Лоли', value: '13' }, + { label: 'Мехи', value: '16' }, + { label: 'Мистика', value: '17' }, + { label: 'Научная фантастика', value: '21' }, + { label: 'Переодевания', value: '8' }, + { label: 'Повседневность', value: '28' }, + { label: 'Постапокалипсис', value: '40' }, + { label: 'Приключения', value: '3' }, + { label: 'Психология', value: '18' }, + { label: 'Романтика', value: '19' }, + { label: 'Сверхспособности', value: '31' }, + { label: 'Сёдзё', value: '24' }, + { label: 'Сёдзё Ай', value: '25' }, + { label: 'Сёнэн', value: '26' }, + { label: 'Сёнэн Ай', value: '27' }, + { label: 'Сётакон', value: '23' }, + { label: 'Смат', value: '29' }, + { label: 'Спорт', value: '30' }, + { label: 'Сэйнэн', value: '39' }, + { label: 'Сюаньхуа', value: '35' }, + { label: 'Сянься', value: '34' }, + { label: 'Трагедия', value: '32' }, + { label: 'Ужасы', value: '11' }, + { label: 'Уся', value: '33' }, + { label: 'Фэнтези', value: '7' }, + { label: 'Шенён', value: '22' }, + { label: 'Школьные будни', value: '20' }, + { label: 'Экшн', value: '1' }, + { label: 'Этти', value: '6' }, + { label: 'Юмор', value: '4' }, + { label: 'Юри', value: '37' }, + ], + type: FilterTypes.Picker, + }, + } satisfies Filters; +} + +export default new RanobeMe(); diff --git a/public/static/src/ru/ranobeme/icon.png b/public/static/src/ru/ranobeme/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ebe89bfb18b2201af06b850c2295bcbab45700cd GIT binary patch literal 3108 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 U4B2;yLH__#BP(Q$fm`%{0dj2TdH?_b literal 0 HcmV?d00001 From eb5ca4b1abc420415005d9cc217d9aac83c95539 Mon Sep 17 00:00:00 2001 From: RikCost <69048938+RikCost@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:04:20 +0300 Subject: [PATCH 2/2] fix(russian/ranobeme): keep absolute URLs in resolveUrl Co-Authored-By: Claude Opus 5.5 --- plugins/russian/ranobeme.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/russian/ranobeme.ts b/plugins/russian/ranobeme.ts index e47b2063e..d6b125c0b 100644 --- a/plugins/russian/ranobeme.ts +++ b/plugins/russian/ranobeme.ts @@ -168,7 +168,8 @@ class RanobeMe implements Plugin.PluginBase { ); } - resolveUrl = (path: string) => this.site + path; + resolveUrl = (path: string) => + /^https?:\/\//i.test(path) ? path : this.site + path; filters = { sort: {