Files
lnreader-plugins/plugins/english/relibrary.ts
T
Rajarshee Chatterjee 20c2b75b01 refactor: Overhaul Plugin Architecture & Playground UI (#1846)
* refactor: Move Plugins Out and Consolidate with Multisrc

* feat: Overhaul Playground UI

* feat: Add Filters to New UI

* feat: Persist Selected Plugin

* feat: Add Direct Parse Novel/Chapter Options and Feedback Toasts

[skip ci]

* feat: UX Improvements

* chore: Update Plugin Path in Publish Workflow

* fix: Add Backward Compatibility

* chore: Update Plugin Publishing Script
2025-10-20 22:54:08 +05:30

428 lines
14 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 { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
import { NovelItem } from '../../test_web/static/js';
import { NovelStatus } from '@libs/novelStatus';
type FuzzySearchOptions = {
caseSensitive: boolean;
sort: boolean;
};
// Shamelessly stolen from 'https://raw.githubusercontent.com/wouterrutgers/fuzzy-search/master/src/FuzzySearch.mjs'
// I did modify the code a fair bit, but the algorithm is still the same under the hood.
// The original code is under ISC, which stipluate that it needs to retain the copyright notice, so here it is.
//
// It only applies to the code in the FuzzySearch Class, even though I did modify it a fair bit
/*
Copyright (c) 2016, Wouter Rutgers
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
class FuzzySearch<Item> {
private haystack: Item[];
private options: FuzzySearchOptions;
private getItems: (item: Item) => string[];
constructor(
getItems: (item: Item) => string[],
options: Partial<FuzzySearchOptions>,
) {
this.haystack = [];
this.options = Object.assign(
{
caseSensitive: false,
sort: false,
},
options,
);
this.getItems = getItems;
}
getOptions(): FuzzySearchOptions {
return this.options;
}
setOptions(options: Partial<FuzzySearchOptions>) {
this.options = Object.assign(this.options, options);
}
setHaystack(items: Item[]) {
this.haystack = items;
}
getHaystack(): Item[] {
return this.haystack;
}
search(query: string): Item[] {
if (query.length === 0) {
return this.haystack;
}
const results: { item: Item; score: number }[] = [];
for (const item of this.haystack) {
for (const value of this.getItems(item)) {
const score = this.isMatch(value, query);
if (score !== undefined) {
results.push({ item, score });
/// We don't want to have multiple occurence of the same item, so only take one if it is found
break;
}
}
}
if (this.options.sort) {
results.sort((a, b) => a.score - b.score);
}
return results.map(result => result.item);
}
isMatch(item: string, query: string): number | undefined {
if (!this.options.caseSensitive) {
item = item.toLocaleLowerCase();
query = query.toLocaleLowerCase();
}
const indexes = this.nearestIndexesFor(item, query);
if (indexes === undefined) {
return;
}
// Exact matches should be first.
if (item === query) {
return 1;
}
// If we have more than 2 letters, matches close to each other should be first.
if (indexes.length > 1) {
return 2 + (indexes[indexes.length - 1] - indexes[0]);
}
// Matches closest to the start of the string should be first.
return 2 + indexes[0];
}
nearestIndexesFor(item: string, query: string): number[] | undefined {
const letters = query.split('');
const indexes: number[][] = [];
const idxFL = this.idxFirstLetter(item, query);
for (const idx of idxFL) {
indexes.push([idx]);
for (let i = 1; i < letters.length; i++) {
const letter = letters[i];
let index = item.indexOf(letter, idx + 1);
if (index === -1) {
indexes.pop();
break;
}
indexes[indexes.length - 1].push(index);
index++;
}
}
if (indexes.length === 0) {
return;
}
return indexes.sort((a, b) => {
if (a.length === 1) {
return a[0] - b[0];
}
const res_a = a[a.length - 1] - a[0];
const res_b = b[b.length - 1] - b[0];
return res_a - res_b;
})[0];
}
idxFirstLetter(item: string, query: string): number[] {
const match = query[0];
return item
.split('')
.map((letter, index) => {
if (letter !== match) {
return;
}
return index;
})
.filter(index => index !== undefined) as number[];
}
}
class ReLibraryPlugin implements Plugin.PluginBase {
id = 'ReLib';
name = 'Re:Library';
icon = 'src/en/relibrary/icon.png';
site = 'https://re-library.com';
version = '1.0.2';
imageRequestInit: Plugin.ImageRequestInit = {
headers: {
Referer: 'https://re-library.com/',
},
};
private searchFunc = new FuzzySearch<Plugin.NovelItem>(item => [item.name], {
sort: true,
caseSensitive: false,
});
private async popularNovelsInner(url: string): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = loadCheerio(body);
loadedCheerio('.entry-content > ol > li').each((_i, el) => {
const novel: Partial<NovelItem> = {};
novel.name = loadedCheerio(el).find('h3 > a').text();
novel.path = loadedCheerio(el)
.find('table > tbody > tr > td > a')
.attr('href');
if (novel.name === undefined || novel.path === undefined) return;
novel.cover =
loadedCheerio(el)
.find('table > tbody > tr > td > a > img')
.attr('data-cfsrc') ||
loadedCheerio(el)
.find('table > tbody > tr > td > a > img')
.attr('src') ||
defaultCover;
if (novel.path.startsWith(this.site)) {
novel.path = novel.path.slice(this.site.length);
}
novels.push(novel as Plugin.NovelItem);
});
return novels;
}
private async lastestNovelsInner(url: string): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = loadCheerio(body);
loadedCheerio('article.type-page.page').each((_i, el) => {
const novel: Partial<Plugin.NovelItem> = {};
novel.name = loadedCheerio(el).find('.entry-title').text();
novel.path = loadedCheerio(el).find('.entry-title a').attr('href');
if (novel.path === undefined || novel.name === undefined) return;
novel.cover =
loadedCheerio(el)
.find('.entry-content > table > tbody > tr > td > a >img')
.attr('data-cfsrc') ||
loadedCheerio(el)
.find('.entry-content > table > tbody > tr > td > a >img')
.attr('src') ||
defaultCover;
if (novel.path.startsWith(this.site)) {
novel.path = novel.path.slice(this.site.length);
}
novels.push(novel as Plugin.NovelItem);
});
return novels;
}
async popularNovels(
pageNo: number,
{ showLatestNovels }: Plugin.PopularNovelsOptions<Filters>,
): Promise<Plugin.NovelItem[]> {
// The most-popular page only has a single page, so we return an empty array in case you ask for the impossible
// the lastest page do have paginated result, so we support that.
if (showLatestNovels)
return this.lastestNovelsInner(
`${this.site}/tag/translations/page/${pageNo}`,
);
else if (pageNo === 1)
return this.popularNovelsInner(`${this.site}/translations/most-popular/`);
else return [];
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novel: Partial<Plugin.SourceNovel> = {
path: novelPath,
};
// title: .entry-content > header.entry-header > .entry-title
// img: .entry-content > table > tbody > tr > td > img
// tags: .entry-content > table > tbody > tr > td > p > span > a[]
// synopis: .entry-content > div.su-box > div.su-box-content
// chapters: .entry-content > div.su-accordion <then> li.page_item[]
const result = await fetchApi(`${this.site}/${novelPath}`);
const body = await result.text();
const loadedCheerio = loadCheerio(body);
// If it doesn't find the name I should just throw an error (or early return) since the scraping is broken
novel.name = loadedCheerio('header.entry-header > .entry-title')
.text()
.trim();
if (novel.name === undefined || novel.name === '404 – Page not found')
throw new Error(`Invalid novel for url ${novelPath}`);
// Find the cover
novel.cover =
loadedCheerio('.entry-content > table img').attr('data-cfsrc') ||
loadedCheerio('.entry-content > table img').attr('src') ||
defaultCover;
novel.status = NovelStatus.Unknown;
loadedCheerio('.entry-content > table > tbody > tr > td > p').each(
function (_i, el) {
// Handle the novel status
// Sadly some novels just state the status inside the summary...
if (
loadedCheerio(el)
.find('strong')
.text()
.toLowerCase()
.trim()
.startsWith('status')
) {
loadedCheerio(el).find('strong').remove();
const status = loadedCheerio(el).text().toLowerCase().trim();
if (status.includes('on-going')) {
novel.status = NovelStatus.Ongoing;
} else if (status.includes('completed')) {
novel.status = NovelStatus.Completed;
} else if (status.includes('hiatus')) {
novel.status = NovelStatus.OnHiatus;
} else if (status.includes('cancelled')) {
novel.status = NovelStatus.Cancelled;
} else {
novel.status = loadCheerio(el).text();
}
}
// Handle the genres
else if (
loadedCheerio(el)
.find('strong')
.text()
.toLowerCase()
.trim()
.startsWith('Category')
) {
loadedCheerio(el).find('strong').remove();
novel.genres = loadedCheerio(el).text();
}
},
);
novel.summary = loadedCheerio(
'.entry-content > div.su-box > div.su-box-content',
).text();
const chapters: Plugin.ChapterItem[] = [];
let chapter_idx = 0;
loadedCheerio('.entry-content > div.su-accordion').each((_i1, el) => {
loadedCheerio(el)
.find('li > a')
.each((_i2, chap_el) => {
chapter_idx += 1;
let chap_path = loadedCheerio(chap_el).attr('href')?.trim();
if (
loadedCheerio(chap_el).text() === undefined ||
chap_path === undefined
)
return;
if (chap_path.startsWith(this.site)) {
chap_path = chap_path.slice(this.site.length);
}
chapters.push({
name: loadedCheerio(chap_el).text(),
path: chap_path,
chapterNumber: chapter_idx,
// we KNOW that we can't get the released time (at least without any additional fetches), so set it to null purposfully
releaseTime: null,
});
});
});
novel.chapters = chapters;
return novel as Plugin.NovelItem;
}
async parseChapter(chapterPath: string): Promise<string> {
// parse chapter text here
const result = await fetchApi(`${this.site}/${chapterPath}`);
const body = await result.text();
const loadedCheerio = loadCheerio(body);
const entryContent = loadedCheerio('.entry-content');
const pageLinkHr = entryContent.find('.PageLink + hr').first();
if (pageLinkHr.length) {
// Remove all previous siblings before the .PageLink + hr
let prev = pageLinkHr.prev();
while (prev.length) {
prev.remove();
prev = pageLinkHr.prev();
}
const pageLink = pageLinkHr.prev('.PageLink');
if (pageLink.length) {
pageLink.remove();
}
pageLinkHr.next().remove();
pageLinkHr.remove();
}
// Find the first <hr> followed by a .PageLink and remove everything after
const hrAfterPageLink = entryContent.find('hr + .PageLink').first();
if (hrAfterPageLink.length) {
let next = hrAfterPageLink.next();
while (next.length) {
const temp = next.next();
next.remove();
next = temp;
}
hrAfterPageLink.prev().remove();
hrAfterPageLink.remove();
}
return entryContent.html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
// We only want to serve a single "page" since we do the search client side.
if (pageNo !== 1) return [];
const novels: Plugin.NovelItem[] = [];
const req = await fetchApi(`${this.site}/translations/`);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
loadedCheerio('article article').each((_i, el) => {
const e = loadedCheerio(el);
if (e.find('a').attr('href') && e.find('a').text()) {
novels.push({
name: e.find('h4').text(),
path: e.find('a').attr('href')?.replace(this.site, '') || '',
cover:
e.find('img').attr('data-cfsrc') ||
e.find('img').attr('src') ||
defaultCover,
});
}
});
this.searchFunc.setHaystack(novels);
return this.searchFunc.search(searchTerm);
}
}
export default new ReLibraryPlugin();