Skip to content
Open
81 changes: 39 additions & 42 deletions plugins/arabic/galaxynovels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,10 @@ type ChaptersIndex = {
chapters: ChapterJSON[];
};

type ChapterContentResponse = {
schema: number;
data: {
content_html: string;
display_title: string;
navigation: { next_url: string; previous_url: string };
};
};

class GalaxyNovels implements Plugin.PluginBase {
id = 'galaxynovels';
name = 'Galaxy Novels';
version = '1.1.0';
version = '1.1.1';
icon = 'src/ar/galaxynovels/icon.png';
site = 'https://galaxynovels.com/';

Expand Down Expand Up @@ -62,6 +53,11 @@ class GalaxyNovels implements Plugin.PluginBase {

private baseUrl = 'https://galaxynovels.com';

private toChapterPath(url: string | undefined): string {
if (!url) return '';
return url.startsWith('http') ? new URL(url).pathname : url;
}

private async fetchHtml(url: string): Promise<string> {
const res = await fetchApi(url);
if (!res.ok) {
Expand Down Expand Up @@ -106,8 +102,7 @@ class GalaxyNovels implements Plugin.PluginBase {
const coverLink = $el.find('a.wor-novel-card__cover');
const href = coverLink.attr('href');
const img = $el.find('img.wor-cover-img');
const cover =
img.attr('data-src') || img.attr('src') || undefined;
const cover = img.attr('data-src') || img.attr('src') || undefined;
const title = $el.find('h3 a').text().trim();

if (!href || !title) return;
Expand Down Expand Up @@ -162,7 +157,7 @@ class GalaxyNovels implements Plugin.PluginBase {

chapters = index.chapters.map(ch => ({
name: ch.label + (ch.title ? `: ${ch.title}` : ''),
path: `${novelPath}chapter-${ch.id}/`,
path: this.toChapterPath(ch.url) || `${novelPath}chapter-${ch.id}/`,
chapterNumber: ch.position,
releaseTime: ch.date_iso?.split('T')[0] || '',
}));
Expand All @@ -174,17 +169,21 @@ class GalaxyNovels implements Plugin.PluginBase {
if (chapters.length === 0) {
$('article.wor-novel-chapter-item').each((_, el) => {
const $el = $(el);
const chapterLink = $el.find('h3 a').attr('href') || $el.find('a.wor-novel-chapter-item__num').attr('href');
const chapterName = $el.find('h3 a').text().trim() || $el.find('a.wor-novel-chapter-item__num').text().trim();
const chapterId = $el.attr('data-chapter-id');
const chapterLink =
$el.find('h3 a').attr('href') ||
$el.find('a.wor-novel-chapter-item__num').attr('href');
const chapterName =
$el.find('h3 a').text().trim() ||
$el.find('a.wor-novel-chapter-item__num').text().trim();
const timeEl = $el.find('time');
const releaseTime = timeEl.attr('datetime')?.split('T')[0] || '';

if (!chapterLink) return;

const path = chapterId
? `${novelPath}chapter-${chapterId}/`
: new URL(chapterLink, this.site).pathname;
// Only the full permalink resolves on the site; the data-chapter-id
// attribute holds the WordPress post id, and …/chapter-<post-id>/
// answers 404.
const path = this.toChapterPath(chapterLink);
const numMatch = path.match(/chapter-(\d+)/);
const chapterNumber = numMatch ? parseInt(numMatch[1]) : 0;

Expand All @@ -210,27 +209,29 @@ class GalaxyNovels implements Plugin.PluginBase {
}

async parseChapter(chapterPath: string): Promise<string> {
const idMatch = chapterPath.match(/chapter-(\d+)/);
const chapterId = idMatch?.[1];
const url = `${this.baseUrl}${chapterPath}`;
const html = await this.fetchHtml(url);
const $ = loadCheerio(html);

if (chapterId) {
const apiUrl = `${this.baseUrl}/wp-json/wor-reader-app/v1/chapters/${chapterId}`;
try {
const response = await this.fetchJson<ChapterContentResponse>(apiUrl);
if (response.data?.content_html) {
return response.data.content_html;
}
} catch {
// fallback to HTML
// The body is server-rendered inside the article marked up as
// https://schema.org/Chapter, as the element with itemprop="text". Pick
// the first candidate that actually carries text: a themed wrapper can be
// present but empty, and cheerio selections are always truthy.
for (const selector of [
'article[itemtype*="Chapter"] [itemprop="text"]',
'[itemprop="text"]',
'[data-wor-reader-text]',
'.wor-reader-text-surface',
]) {
const candidate = $(selector);
if (candidate.length && candidate.text().trim()) {
return candidate.html() || '';
}
}

const url = `${this.baseUrl}${chapterPath}`;
const html = await this.fetchHtml(url);
const $ = loadCheerio(html);
const content =
$('article.wor-chapter-content, .wor-chapter-text, .entry-content').html();
return content || '<p>Content not available.</p>';
// Throw rather than return placeholder text, which the reader would show
// (and a download would save) as if it were the chapter.
throw new Error('Chapter text not found on the page, open it in WebView.');
}

async searchNovels(
Expand All @@ -254,9 +255,7 @@ class GalaxyNovels implements Plugin.PluginBase {

const term = searchTerm.toLowerCase();
const filtered = searchIndex.items.filter(
n =>
n.t.toLowerCase().includes(term) ||
n.s.toLowerCase().includes(term),
n => n.t.toLowerCase().includes(term) || n.s.toLowerCase().includes(term),
);

const limit = 20;
Expand All @@ -265,9 +264,7 @@ class GalaxyNovels implements Plugin.PluginBase {
return filtered.slice(offset, offset + limit).map(novel => ({
name: novel.t,
path: novel.u,
cover: novel.c.startsWith('http')
? novel.c
: `${this.baseUrl}${novel.c}`,
cover: novel.c.startsWith('http') ? novel.c : `${this.baseUrl}${novel.c}`,
}));
}
}
Expand Down
33 changes: 24 additions & 9 deletions plugins/multisrc/lightnovelwp/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class LightNovelWPPlugin implements Plugin.PluginBase {
this.icon = `multisrc/lightnovelwp/${metadata.id.toLowerCase()}/icon.png`;
this.site = metadata.sourceSite;
const versionIncrements = metadata.options?.versionIncrements || 0;
this.version = `1.1.${11 + versionIncrements}`;
this.version = `1.2.${11 + versionIncrements}`;
this.options = metadata.options ?? ({} as LightNovelWPOptions);
this.filters = metadata.filters satisfies Filters;

Expand Down Expand Up @@ -220,7 +220,13 @@ export class LightNovelWPPlugin implements Plugin.PluginBase {
isReadingChapter = true;
} else if (isReadingChapter) {
if (name === 'a' && tempChapter.path === undefined) {
tempChapter.path = attribs['href'].replace(baseURL, '').trim();
// Some installs link chapters with the absolute URL of the main
// site (free.kolnovel.com links kolnovel.com), and parseChapter
// re-prefixes this.site, so keep the path only.
tempChapter.path = attribs['href']
.replace(baseURL, '')
.replace(/^https?:\/\/[^/]+/, '')
.trim();
} else if (attribs['class'] === 'epl-num') {
isReadingChapterInfo = 1;
} else if (attribs['class'] === 'epl-title') {
Expand Down Expand Up @@ -438,17 +444,21 @@ export class LightNovelWPPlugin implements Plugin.PluginBase {
throw error;
}
}
// The chapter lives in the `epcontent` block. Older child themes closed
// it with a `bottomnav` div, but that wrapper is gone on current ones, so
// select the element itself rather than cutting the document between
// landmarks: a <script> inside the block then cannot end it early either.
// The chapter lives in the `epcontent` block, which newer installs render
// as an <article> instead of a <div> and no longer close with a
// `bottomnav` div, so select the element itself rather than cutting the
// document between landmarks: a <script> inside the block then cannot end
// it early either.
const $ = load(data);
const content = $('div.epcontent').first();
const content = $('.epcontent').first();

// No epcontent block means this was not a chapter page (a login wall or
// an error page served with 200). Returning the whole document here would
// show that page's text as the chapter, so report nothing instead.
if (!content.length) return '';
// show that page's text as the chapter, so fail instead.
if (!content.length)
throw new Error(
'Chapter text not found on the page, try to open in webview.',
);

content.find('script, style, noscript').remove();
const pageUrl = this.site + chapterPath;
Expand Down Expand Up @@ -506,8 +516,13 @@ export class LightNovelWPPlugin implements Plugin.PluginBase {
}
});

if (!blocks.length)
throw new Error(
'Chapter text not found on the page, try to open in webview.',
);
return blocks.join('\n');
}

async searchNovels(
searchTerm: string,
page: number,
Expand Down
2 changes: 1 addition & 1 deletion plugins/multisrc/madara/sources.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@
"options": {
"useNewChapterEndpoint": true,
"lang": "Arabic",
"customJs": "chapterText.find('span[style*=\"opacity: 0; position: fixed;\"],[role=\"presentation\"]').remove();"
"customJs": "chapterText.find('style,script,[inert],[data-nosnippet],[data-nhv-reader-promo]').remove();chapterText.find('span[style*=\"opacity: 0; position: fixed;\"],[role=\"presentation\"]').remove();"
}
},
{
Expand Down
75 changes: 69 additions & 6 deletions plugins/multisrc/madara/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export class MadaraPlugin implements Plugin.PluginBase {
this.icon = `multisrc/madara/${metadata.id.toLowerCase()}/icon.png`;
this.site = metadata.sourceSite;
const versionIncrements = metadata.options?.versionIncrements || 0;
this.version = `2.2.${versionIncrements}`;
this.version = `2.3.${versionIncrements}`;
this.options = metadata.options;
this.filters = metadata.filters;

Expand Down Expand Up @@ -202,13 +202,17 @@ export class MadaraPlugin implements Plugin.PluginBase {
loadedCheerio('.post-title h1').text().trim() ||
loadedCheerio('#manga-title h1').text().trim() ||
loadedCheerio('.manga-title').text().trim() ||
loadedCheerio('h1.nhv-novel-title').text().trim() ||
'',
};

novel.cover =
loadedCheerio('.summary_image > a > img').attr('data-lazy-src') ||
loadedCheerio('.summary_image > a > img').attr('data-src') ||
loadedCheerio('.summary_image > a > img').attr('src') ||
loadedCheerio('.nhv-novel-cover img').attr('data-lazy-src') ||
loadedCheerio('.nhv-novel-cover img').attr('data-src') ||
loadedCheerio('.nhv-novel-cover img').attr('src') ||
defaultCover;

loadedCheerio('.post-content_item, .post-content').each(function () {
Expand Down Expand Up @@ -269,6 +273,36 @@ export class MadaraPlugin implements Plugin.PluginBase {
}
});

// Fallback for the custom "NHV" theme (e.g. cenele.com), which renders
// novel pages without the classic Madara detail blocks. Every field is
// only filled when the selectors above left it empty, so sources on the
// classic theme keep their existing behaviour.
{
if (!novel.author)
novel.author =
loadedCheerio('.nhv-novel-meta a[href*="cont-author"]')
.first()
.text()
.trim() ||
loadedCheerio('a[href*="cont-author"]').first().text().trim();
if (!novel.genres)
novel.genres = loadedCheerio('.nhv-novel-genres a')
.map((i, el) => loadedCheerio(el).text())
.get()
.join(', ');
const nhvStatus = loadedCheerio('.nhv-novel-status').text().trim();
if (!novel.status && nhvStatus)
novel.status = nhvStatus.includes('مستمرة')
? NovelStatus.Ongoing
: NovelStatus.Completed;
if (!novel.rating) {
const nhvRating = parseFloat(
loadedCheerio('.nhv-simple-rating__avg').text().trim(),
);

if (!isNaN(nhvRating)) novel.rating = nhvRating;
}
}
// Checks for "Madara NovelHub" version
{
if (!novel.genres)
Expand Down Expand Up @@ -317,6 +351,11 @@ export class MadaraPlugin implements Plugin.PluginBase {
.join('\n\n')
.trim() ||
loadedCheerio('.manga-excerpt p')
.map((i, el) => loadedCheerio(el).text())
.get()
.join('\n\n')
.trim() ||
loadedCheerio('.nhv-novel-synopsis p')
.map((i, el) => loadedCheerio(el).text())
.get()
.join('\n\n')
Expand Down Expand Up @@ -416,11 +455,30 @@ export class MadaraPlugin implements Plugin.PluginBase {

async parseChapter(chapterPath: string): Promise<string> {
const loadedCheerio = await this.getCheerio(this.site + chapterPath, false);
const chapterText =
loadedCheerio('.text-left') ||
loadedCheerio('.text-right') ||
loadedCheerio('.entry-content') ||
loadedCheerio('.c-blog-post > div > div:nth-child(2)');

// A cheerio selection is always truthy, so the previous
// `$('.text-left') || $('.text-right') || …` chain never looked past the
// first selector: on a site that no longer renders `.text-left` it
// returned an empty body no matter what else the page offered. Walk the
// candidates and keep the first one that actually holds text or images
// (illustration and comic chapters have no text but did render before).
let chapterText = loadedCheerio('.__no-chapter-content__');
for (const selector of [
'.text-left',
'.text-right',
'.text-content',
'.text-chapter-content',
'novel-chapter',
'.reading-content',
'.entry-content',
'.c-blog-post > div > div:nth-child(2)',
]) {
const candidate = loadedCheerio(selector);
if (candidate.text().trim() || candidate.find('img').length) {
chapterText = candidate;
break;
}
}

if (this.options?.customJs) {
try {
Expand All @@ -431,6 +489,11 @@ export class MadaraPlugin implements Plugin.PluginBase {
}
}

if (!chapterText.length)
throw new Error(
'Chapter text not found on the page, try to open in webview.',
);

return this.translateDragontea(chapterText).html() || '';
}

Expand Down
Loading