perf(lnori): open novels fast on phones (#2446)
Co-authored-by: lipexama <301595999+lipexama@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ class LnorisPlugin implements Plugin.PluginBase {
|
|||||||
name = 'LNORI';
|
name = 'LNORI';
|
||||||
icon = 'src/en/lnori/icon.png';
|
icon = 'src/en/lnori/icon.png';
|
||||||
site = 'https://lnori.com/';
|
site = 'https://lnori.com/';
|
||||||
version = '1.0.0';
|
version = '1.0.1';
|
||||||
|
|
||||||
private async getLibraryNovels(): Promise<
|
private async getLibraryNovels(): Promise<
|
||||||
{
|
{
|
||||||
@@ -168,9 +168,21 @@ class LnorisPlugin implements Plugin.PluginBase {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const volumeUrls = Object.keys(volumeMap);
|
const volumeUrls = Object.keys(volumeMap);
|
||||||
const volumePromises = volumeUrls.map(async volUrl => {
|
|
||||||
const fullVolUrl = this.site.replace(/\/$/, '') + volUrl;
|
/**
|
||||||
const volHtml = await fetchText(fullVolUrl);
|
* Volume pages weigh ~0.5MB each and a long series has dozens, so
|
||||||
|
* building the chapter list used to download and fully parse ~15MB at
|
||||||
|
* once — about a minute of blank screen on a phone, and one failed
|
||||||
|
* request failed the whole novel. The chapter names live in the page's
|
||||||
|
* <nav> table of contents, which is a few KB: parse just that when it
|
||||||
|
* can name every chapter, and fall back to the full page when it
|
||||||
|
* cannot. Volumes are fetched a few at a time for the same reason.
|
||||||
|
*/
|
||||||
|
const parseVolume = (
|
||||||
|
volUrl: string,
|
||||||
|
volHtml: string,
|
||||||
|
isTocOnly: boolean,
|
||||||
|
): Plugin.ChapterItem[] | null => {
|
||||||
const $vol = parseHTML(volHtml);
|
const $vol = parseHTML(volHtml);
|
||||||
|
|
||||||
const volChapters: Plugin.ChapterItem[] = [];
|
const volChapters: Plugin.ChapterItem[] = [];
|
||||||
@@ -179,12 +191,20 @@ class LnorisPlugin implements Plugin.PluginBase {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (tocLinks.length > 0) {
|
if (tocLinks.length > 0) {
|
||||||
|
let needFullPage = false;
|
||||||
tocLinks.each((i, el) => {
|
tocLinks.each((i, el) => {
|
||||||
const href = $vol(el).attr('href');
|
const href = $vol(el).attr('href');
|
||||||
if (!href) return;
|
if (!href) return;
|
||||||
const id = href.substring(1);
|
const id = href.substring(1);
|
||||||
const tocTitle = $vol(el).text().trim().replace(/\s+/g, ' ');
|
const tocTitle = $vol(el).text().trim().replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
// A nameless TOC entry falls back to the chapter's own heading,
|
||||||
|
// which only the full page has.
|
||||||
|
if (isTocOnly && !tocTitle) {
|
||||||
|
needFullPage = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const section = $vol(`section#${id}`);
|
const section = $vol(`section#${id}`);
|
||||||
const h2Title = section
|
const h2Title = section
|
||||||
.find('h2.chapter-title, h2, h3')
|
.find('h2.chapter-title, h2, h3')
|
||||||
@@ -207,6 +227,13 @@ class LnorisPlugin implements Plugin.PluginBase {
|
|||||||
path,
|
path,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if (needFullPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (isTocOnly) {
|
||||||
|
// No anchors in the TOC — chapters can only come from the page's
|
||||||
|
// sections.
|
||||||
|
return null;
|
||||||
} else {
|
} else {
|
||||||
$vol('section.chapter').each((i, el) => {
|
$vol('section.chapter').each((i, el) => {
|
||||||
const id = $vol(el).attr('id');
|
const id = $vol(el).attr('id');
|
||||||
@@ -234,9 +261,40 @@ class LnorisPlugin implements Plugin.PluginBase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return volChapters;
|
return volChapters;
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const sliceToc = (html: string): string | undefined => {
|
||||||
|
const start = html.search(
|
||||||
|
/<nav[^>]*(?:class="[^"]*\btoc-view\b|id="toc-list")/,
|
||||||
|
);
|
||||||
|
if (start === -1) return undefined;
|
||||||
|
const end = html.indexOf('</nav>', start);
|
||||||
|
if (end === -1) return undefined;
|
||||||
|
return html.slice(start, end + '</nav>'.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONCURRENT_VOLUMES = 4;
|
||||||
|
const chapters2D: Plugin.ChapterItem[][] = [];
|
||||||
|
for (let i = 0; i < volumeUrls.length; i += CONCURRENT_VOLUMES) {
|
||||||
|
const batch = volumeUrls.slice(i, i + CONCURRENT_VOLUMES);
|
||||||
|
const results = await Promise.all(
|
||||||
|
batch.map(async volUrl => {
|
||||||
|
const fullVolUrl = this.site.replace(/\/$/, '') + volUrl;
|
||||||
|
const volHtml = await fetchText(fullVolUrl);
|
||||||
|
|
||||||
|
const toc = sliceToc(volHtml);
|
||||||
|
if (toc) {
|
||||||
|
const fromToc = parseVolume(volUrl, toc, true);
|
||||||
|
if (fromToc) {
|
||||||
|
return fromToc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseVolume(volUrl, volHtml, false) ?? [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
chapters2D.push(...results);
|
||||||
|
}
|
||||||
|
|
||||||
const chapters2D = await Promise.all(volumePromises);
|
|
||||||
const chapters = chapters2D.flat();
|
const chapters = chapters2D.flat();
|
||||||
|
|
||||||
novel.chapters = chapters.map((chap, idx) => ({
|
novel.chapters = chapters.map((chap, idx) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user