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
171 changes: 124 additions & 47 deletions plugins/arabic/galaxynovels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type ChapterJSON = {
label: string;
title: string;
url: string;
content_api: string;
content_api?: string;
date_iso: string;
};

Expand All @@ -21,19 +21,23 @@ type ChaptersIndex = {
chapters: ChapterJSON[];
};

type ChapterContentResponse = {
schema: number;
data: {
content_html: string;
display_title: string;
navigation: { next_url: string; previous_url: string };
};
type ChaptersManifest = {
novel_id: number;
total: number;
pack_url: string;
live_tail: ChapterJSON[];
};

type ChaptersPack = {
novel_id: number;
total: number;
chapters: ChapterJSON[];
};

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

Expand Down Expand Up @@ -62,6 +66,71 @@ 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 toChapterItem(
ch: ChapterJSON,
novelPath: string,
): Plugin.ChapterItem {
return {
name: ch.label + (ch.title ? `: ${ch.title}` : ''),
path: this.toChapterPath(ch.url) || `${novelPath}chapter-${ch.id}/`,
chapterNumber: ch.position,
releaseTime: ch.date_iso?.split('T')[0] || '',
};
}

// The novel page only renders the latest 30 chapters (data-per-page="30")
// and the data-index-url endpoint is currently empty, so the full list is
// read from the static chapter manifest instead: the manifest points at a
// pack file holding every chapter, plus a live_tail with the newest ones.
// The tail is merged over the pack the way the site's own reader does, so
// chapters published after the pack was generated are included. Anything
// missing or malformed here returns null so parseNovel falls back to the
// page-derived list instead of an empty chapter list.
private async fetchManifestChapters(
manifestUrl: string | undefined,
novelPath: string,
): Promise<Plugin.ChapterItem[] | null> {
if (!manifestUrl) return null;
try {
const manifestEndpoint = manifestUrl.startsWith('http')
? manifestUrl
: `${this.baseUrl}${manifestUrl}`;
const manifest = await this.fetchJson<ChaptersManifest>(manifestEndpoint);
if (!manifest?.pack_url) return null;

const packEndpoint = manifest.pack_url.startsWith('http')
? manifest.pack_url
: `${this.baseUrl}${manifest.pack_url}`;
const pack = await this.fetchJson<ChaptersPack>(packEndpoint);
if (!pack || !Array.isArray(pack.chapters)) return null;

const byId = new Map<number, ChapterJSON>();
for (const ch of pack.chapters) {
if (ch && typeof ch.id === 'number' && ch.url) byId.set(ch.id, ch);
}
const liveTail = Array.isArray(manifest.live_tail)
? manifest.live_tail
: [];
for (const ch of liveTail) {
if (ch && typeof ch.id === 'number' && ch.url) byId.set(ch.id, ch);
}

const merged = Array.from(byId.values())
.filter(ch => typeof ch.position === 'number')
.sort((a, b) => a.position - b.position);
if (merged.length === 0) return null;

return merged.map(ch => this.toChapterItem(ch, novelPath));
} catch {
return null;
}
}

private async fetchHtml(url: string): Promise<string> {
const res = await fetchApi(url);
if (!res.ok) {
Expand Down Expand Up @@ -106,8 +175,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 @@ -150,22 +218,26 @@ class GalaxyNovels implements Plugin.PluginBase {

const chaptersContainer = $('[data-wor-chapters-container]');
const chaptersIndexUrl = chaptersContainer.attr('data-index-url');
const chaptersManifestUrl = chaptersContainer.attr('data-manifest-url');

let chapters: Plugin.ChapterItem[] = [];

if (chaptersIndexUrl) {
const manifestChapters = await this.fetchManifestChapters(
chaptersManifestUrl,
novelPath,
);
if (manifestChapters && manifestChapters.length > 0) {
chapters = manifestChapters;
}

if (chapters.length === 0 && chaptersIndexUrl) {
try {
const indexUrl = chaptersIndexUrl.startsWith('http')
? chaptersIndexUrl
: `${this.baseUrl}${chaptersIndexUrl}`;
const index = await this.fetchJson<ChaptersIndex>(indexUrl);

chapters = index.chapters.map(ch => ({
name: ch.label + (ch.title ? `: ${ch.title}` : ''),
path: `${novelPath}chapter-${ch.id}/`,
chapterNumber: ch.position,
releaseTime: ch.date_iso?.split('T')[0] || '',
}));
chapters = index.chapters.map(ch => this.toChapterItem(ch, novelPath));
} catch {
// fallback to HTML parsing
}
Expand All @@ -174,17 +246,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 @@ -195,6 +271,9 @@ class GalaxyNovels implements Plugin.PluginBase {
releaseTime,
});
});
// The page lists the newest chapter first (data-order="desc"), but the
// app expects the oldest first.
if (chaptersContainer.attr('data-order') === 'desc') chapters.reverse();
}

return {
Expand All @@ -210,27 +289,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 +335,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 +344,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
31 changes: 23 additions & 8 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 = $('.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
Loading
Loading