Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
360 changes: 360 additions & 0 deletions plugins/english/lnori.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,360 @@
import { fetchText } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { load as parseHTML } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';

class LnorisPlugin implements Plugin.PluginBase {
id = 'lnori';
name = 'LNORI';
icon = 'src/en/lnori/icon.png';
site = 'https://lnori.com/';
version = '1.0.0';

async popularNovels(
pageNo: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const url = this.site + 'library';
const body = await fetchText(url);
const $ = parseHTML(body);

const parsedList: {
novel: Plugin.NovelItem;
author: string;
tags: string[];
}[] = [];

$('article.card').each((i, el) => {
const name = $(el).attr('data-t') || '';
const author = $(el).attr('data-a') || '';
const tagsAttr = $(el).attr('data-tags') || '';
const tags = tagsAttr.split(',').map(t => t.trim().toLowerCase());

const coverImg = $(el).find('.card-cover img').first();
let cover = coverImg.attr('src') || '';
if (cover && cover.startsWith('/')) {
cover = this.site + cover.substring(1);
}

const link = $(el).find('a.stretched-link').first();
let path = link.attr('href') || '';
if (path.startsWith('/')) {
path = path.substring(1);
}

if (path && name) {
parsedList.push({
novel: {
name,
path,
cover: cover || defaultCover,
},
author,
tags,
});
}
});

let filteredList = parsedList;
const selectedGenre = filters?.genre?.value;
if (selectedGenre) {
filteredList = filteredList.filter(item =>
item.tags.includes(selectedGenre.toLowerCase()),
);
}

const selectedSort = filters?.sort?.value;
if (selectedSort === 'title-az') {
filteredList.sort((a, b) => a.novel.name.localeCompare(b.novel.name));
} else if (selectedSort === 'title-za') {
filteredList.sort((a, b) => b.novel.name.localeCompare(a.novel.name));
}

const pageSize = 36;
const offset = (pageNo - 1) * pageSize;
return filteredList
.slice(offset, offset + pageSize)
.map(item => item.novel);
}

async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const body = await fetchText(url);
const $ = parseHTML(body);

const novel: Plugin.SourceNovel = {
path: novelPath,
name: $('.hero-card h1.s-title').text().trim() || 'Untitled',
};

const coverUrl = $('.hero-card .cover-wrap img').attr('src');
if (coverUrl) {
novel.cover = coverUrl.startsWith('/')
? this.site + coverUrl.substring(1)
: coverUrl;
} else {
novel.cover = defaultCover;
}

const dataTagsAttr = $('nav.tags-box.desktop').attr('data-tags');
if (dataTagsAttr) {
try {
const parsedTags = JSON.parse(dataTagsAttr);
novel.genres = parsedTags
.map((t: { name: string }) => t.name)
.join(', ');
} catch (e) {
// Fallback
}
}

if (!novel.genres) {
const genres: string[] = [];
$('nav.tags-box.desktop a, nav.tags-box a').each((i, el) => {
const text = $(el).text().trim();
if (text) genres.push(text);
});
novel.genres = genres.join(', ');
}

const summaryParagraphs: string[] = [];
$('section.desc-box p.description').each((i, el) => {
const text = $(el).text().trim();
if (text) summaryParagraphs.push(text);
});
novel.summary = summaryParagraphs.join('\n\n');

novel.author = $('.hero-card p.author').text().trim();

// Map unique volume URLs
const volumeMap: Record<string, string> = {};
$('a[href^="/book/"]').each((i, el) => {
const href = $(el).attr('href');
const text = $(el).text().trim().replace(/\s+/g, ' ');
if (href) {
if (
!volumeMap[href] ||
(text && text.length > volumeMap[href].length)
) {
volumeMap[href] = text;
}
}
});

const getVolumeName = (href: string, text: string) => {
let cleanText = text.replace(/Start Reading/gi, '').trim();
if (!cleanText) {
const parts = href.split('/');
const slug = parts[parts.length - 1] || parts[parts.length - 2] || '';
cleanText = slug
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
return cleanText;
};

const volumeUrls = Object.keys(volumeMap);
const volumePromises = volumeUrls.map(async volUrl => {
const fullVolUrl = this.site.replace(/\/$/, '') + volUrl;
const volHtml = await fetchText(fullVolUrl);
const $vol = parseHTML(volHtml);

const tocMap: Record<string, string> = {};
$vol('nav.toc-view a[href^="#"], nav#toc-list a[href^="#"]').each(
(i, el) => {
const href = $vol(el).attr('href');
const text = $vol(el).text().trim().replace(/\s+/g, ' ');
if (href && text) {
tocMap[href.substring(1)] = text;
}
},
);

const volChapters: Plugin.ChapterItem[] = [];
$vol('section.chapter').each((i, el) => {
const id = $vol(el).attr('id');
if (id) {
const tocTitle = tocMap[id];
const h2Title = $vol(el)
.find('h2.chapter-title, h2, h3')
.first()
.text()
.trim();
const chapterName =
tocTitle || h2Title || `Page ${id.replace(/\D/g, '')}`;

if (!tocTitle && !h2Title) return;

const volTitle = getVolumeName(volUrl, volumeMap[volUrl]);
let path = volUrl;
if (path.startsWith('/')) {
path = path.substring(1);
}
path = path + '#' + id;

volChapters.push({
name: `${volTitle} - ${chapterName}`,
path,
});
}
});
return volChapters;
});

const chapters2D = await Promise.all(volumePromises);
const chapters = chapters2D.flat();

novel.chapters = chapters.map((chap, idx) => ({
...chap,
chapterNumber: idx + 1,
}));

return novel;
}

async parseChapter(chapterPath: string): Promise<string> {
const [pathWithoutAnchor, anchor] = chapterPath.split('#');
const url = this.site.replace(/\/$/, '') + '/' + pathWithoutAnchor;

const body = await fetchText(url);
const $ = parseHTML(body);

const chapterSelector = anchor ? `section#${anchor}` : 'section.chapter';
const section = $(chapterSelector);

if (!section.length) {
throw new Error(`Chapter section not found: ${chapterPath}`);
}

const mainContent = section.find('.main').length
? section.find('.main').clone()
: section.clone();

mainContent.find('h2, h3, .chapter-title').remove();

mainContent.find('img').each((i, el) => {
const src = $(el).attr('src');
if (src && src.startsWith('/')) {
$(el).attr('src', this.site.replace(/\/$/, '') + src);
}
});

mainContent.find('source').each((i, el) => {
const srcset = $(el).attr('srcset');
if (srcset && srcset.startsWith('/')) {
$(el).attr('srcset', this.site.replace(/\/$/, '') + srcset);
}
});

return mainContent.html() || '';
}

async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const url = this.site + 'library';
const body = await fetchText(url);
const $ = parseHTML(body);

const parsedList: {
novel: Plugin.NovelItem;
author: string;
tags: string[];
}[] = [];

$('article.card').each((i, el) => {
const name = $(el).attr('data-t') || '';
const author = $(el).attr('data-a') || '';
const tagsAttr = $(el).attr('data-tags') || '';
const tags = tagsAttr.split(',').map(t => t.trim().toLowerCase());

const coverImg = $(el).find('.card-cover img').first();
let cover = coverImg.attr('src') || '';
if (cover && cover.startsWith('/')) {
cover = this.site + cover.substring(1);
}

const link = $(el).find('a.stretched-link').first();
let path = link.attr('href') || '';
if (path.startsWith('/')) {
path = path.substring(1);
}

if (path && name) {
parsedList.push({
novel: {
name,
path,
cover: cover || defaultCover,
},
author,
tags,
});
}
});

const term = searchTerm.toLowerCase();
const filteredList = parsedList.filter(item => {
return (
item.novel.name.toLowerCase().includes(term) ||
item.author.toLowerCase().includes(term) ||
item.tags.some(t => t.includes(term))
);
});

const pageSize = 36;
const offset = (pageNo - 1) * pageSize;
return filteredList
.slice(offset, offset + pageSize)
.map(item => item.novel);
}

resolveUrl = (path: string, _isNovel?: boolean) => {
return new URL(path, this.site).href;
};

filters = {
sort: {
label: 'Sort By',
value: 'popular',
options: [
{ label: 'Popular (Default)', value: 'popular' },
{ label: 'Title A-Z', value: 'title-az' },
{ label: 'Title Z-A', value: 'title-za' },
],
type: FilterTypes.Picker,
},
genre: {
label: 'Genre',
value: '',
options: [
{ label: 'All', value: '' },
{ label: 'Academy', value: 'academy' },
{ label: 'Action', value: 'action' },
{ label: 'Adventure', value: 'adventure' },
{ label: 'Comedy', value: 'comedy' },
{ label: 'Drama', value: 'drama' },
{ label: 'Fantasy', value: 'fantasy' },
{ label: 'Harem', value: 'harem' },
{ label: 'Historical', value: 'historical' },
{ label: 'Isekai', value: 'isekai' },
{ label: 'Magic', value: 'magic' },
{ label: 'Mystery', value: 'mystery' },
{ label: 'Psychological', value: 'psychological' },
{ label: 'Reincarnation', value: 'reincarnation' },
{ label: 'Romance', value: 'romance' },
{ label: 'Sci-Fi', value: 'sci-fi' },
{ label: 'Slice of Life', value: 'slice-of-life' },
{ label: 'Tragedy', value: 'tragedy' },
{ label: 'Female Protagonist', value: 'female protagonist' },
{ label: 'Male Protagonist', value: 'male protagonist' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}

export default new LnorisPlugin();
Loading
Loading