diff --git a/plugins/arabic/galaxynovels.ts b/plugins/arabic/galaxynovels.ts index 155c1dae7..befb88013 100644 --- a/plugins/arabic/galaxynovels.ts +++ b/plugins/arabic/galaxynovels.ts @@ -11,7 +11,7 @@ type ChapterJSON = { label: string; title: string; url: string; - content_api: string; + content_api?: string; date_iso: string; }; @@ -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/'; @@ -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 { + if (!manifestUrl) return null; + try { + const manifestEndpoint = manifestUrl.startsWith('http') + ? manifestUrl + : `${this.baseUrl}${manifestUrl}`; + const manifest = await this.fetchJson(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(packEndpoint); + if (!pack || !Array.isArray(pack.chapters)) return null; + + const byId = new Map(); + 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 { const res = await fetchApi(url); if (!res.ok) { @@ -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; @@ -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(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 } @@ -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-/ + // answers 404. + const path = this.toChapterPath(chapterLink); const numMatch = path.match(/chapter-(\d+)/); const chapterNumber = numMatch ? parseInt(numMatch[1]) : 0; @@ -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 { @@ -210,27 +289,29 @@ class GalaxyNovels implements Plugin.PluginBase { } async parseChapter(chapterPath: string): Promise { - 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(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 || '

Content not available.

'; + // 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( @@ -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; @@ -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}`, })); } } diff --git a/plugins/multisrc/lightnovelwp/template.ts b/plugins/multisrc/lightnovelwp/template.ts index 26a3bd8e2..1769bdffa 100644 --- a/plugins/multisrc/lightnovelwp/template.ts +++ b/plugins/multisrc/lightnovelwp/template.ts @@ -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; @@ -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') { @@ -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