From 651553b53f32b512066afc14b6a838a408707df4 Mon Sep 17 00:00:00 2001 From: hamedhani1998 Date: Sat, 26 Sep 2026 09:27:44 +0300 Subject: [PATCH 1/4] Fix GalaxyNovels 403: send a browser UA, drop the blocked Sec-Fetch pair Cloudflare answers every GalaxyNovels request with a hard WAF 403 ("Sorry, you have been blocked"), not a solvable JS challenge, so cf_clearance/WebView solving cannot help. Two independent request signatures trigger it, both measured against the live site: 1. OkHttp's default `okhttp/x.y.z` User-Agent is rejected on every path. The source sent no User-Agent of its own, so the app-wide default was used and even list pages that the site serves fine to a real browser came back 403. 2. `Accept-Language` sent together with BOTH `Sec-Fetch-Dest: document` AND `Sec-Fetch-Mode: navigate`. Either Sec-Fetch header alone passes, and Accept-Language alone passes, but the combination does not. The interceptor now pins a desktop Chrome User-Agent and stops sending Sec-Fetch-Dest/Sec-Fetch-Mode. Referer and Origin already come from KeiSource's headersBuilder(), so the hand-rolled copies in the interceptor and in fetchPageText are gone - fetchPageText had the same broken set and so 403'd on every chapter. This also unblocks the chapter list: because the detail page 403'd, the data-novel-id was never parsed, so the WorReader manifest/pack fetch never ran and every novel fell back to the ~30 chapters in the detail page HTML. With the detail page reachable, the manifest+pack path is used again and returns the full list (verified: 790 chapters for one novel, 939 for another). The legacy per-novel JSON endpoint is removed - it is now dead (403 even with a browser UA), so it only cost a wasted request. Finally, fetchPageText looked for WordPress-style containers, none of which exist on the current WorReader theme, so it fell through to
and returned the whole page. It now selects `div.wor-reader-text-surface`, the reader's actual text container (verified: 52 paragraphs, ~6.4k chars), keeping the old selectors as a fallback. Co-Authored-By: Claude Code --- src/ar/galaxynovels/build.gradle.kts | 2 +- .../extension/ar/galaxynovels/GalaxyNovels.kt | 111 ++++++++---------- 2 files changed, 48 insertions(+), 65 deletions(-) diff --git a/src/ar/galaxynovels/build.gradle.kts b/src/ar/galaxynovels/build.gradle.kts index cd1fd667c..1a0416fd7 100644 --- a/src/ar/galaxynovels/build.gradle.kts +++ b/src/ar/galaxynovels/build.gradle.kts @@ -6,7 +6,7 @@ plugins { keiyoushi { name = "Galaxy Novels" - versionCode = 8 + versionCode = 9 contentWarning = ContentWarning.SAFE source { diff --git a/src/ar/galaxynovels/src/eu/kanade/tachiyomi/extension/ar/galaxynovels/GalaxyNovels.kt b/src/ar/galaxynovels/src/eu/kanade/tachiyomi/extension/ar/galaxynovels/GalaxyNovels.kt index 2de9ccda7..c009b4589 100644 --- a/src/ar/galaxynovels/src/eu/kanade/tachiyomi/extension/ar/galaxynovels/GalaxyNovels.kt +++ b/src/ar/galaxynovels/src/eu/kanade/tachiyomi/extension/ar/galaxynovels/GalaxyNovels.kt @@ -21,7 +21,6 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull -import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor @@ -40,13 +39,52 @@ abstract class GalaxyNovels : private companion object { const val TAG = "GalaxyNovels" + + /** + * A fixed desktop Chrome UA. Do not use the app's random/custom User-Agent here: OkHttp's + * default `okhttp/x.y.z` is what Cloudflare blocks, and this source must always look like a + * browser. See [WorReaderBrowserInterceptor]. + */ + const val BROWSER_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" + + const val HTML_ACCEPT = + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" + + const val ACCEPT_LANGUAGE = "ar,en-US;q=0.7,en;q=0.3" } // Explicit instance with ignoreUnknownKeys — the injected app-wide Json is not // guaranteed to tolerate the extra keys in the WorReader manifest/pack payloads. private val json = Json { ignoreUnknownKeys = true } - override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder = addInterceptor(WorReaderCookieInterceptor(baseUrl)) + /** + * Cloudflare blocks this host on a request signature, not on a solvable JS challenge (a hard + * WAF 403 — "Sorry, you have been blocked" — so cf_clearance/WebView solving cannot help). + * Two independent signatures were measured against the live site: + * + * 1. OkHttp's default `okhttp/x.y.z` User-Agent is blocked on every path, so the default + * client 403s even where the site is reachable. Any real browser UA passes. + * 2. `Accept-Language` together with BOTH `Sec-Fetch-Dest: document` and + * `Sec-Fetch-Mode: navigate` is blocked. Either of the two Sec-Fetch headers alone is fine, + * as is Accept-Language on its own. + * + * So we advertise a plain browser UA and deliberately do not send Sec-Fetch-Dest / + * Sec-Fetch-Mode. Referer/Origin already come from KeiSource's headersBuilder(). + */ + override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder = addInterceptor(WorReaderBrowserInterceptor()) + + private class WorReaderBrowserInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): okhttp3.Response = chain.proceed( + chain.request().newBuilder() + .header("User-Agent", BROWSER_USER_AGENT) + // The site serves its reader shell in JS and expects this opt-in cookie. + .header("Cookie", "wor_reader_js=1") + .header("Accept", HTML_ACCEPT) + .header("Accept-Language", ACCEPT_LANGUAGE) + .build(), + ) + } private fun String?.toAbsoluteUrl(): String? = when { this.isNullOrEmpty() -> null @@ -55,26 +93,6 @@ abstract class GalaxyNovels : else -> this } - private class WorReaderCookieInterceptor(private val base: String) : Interceptor { - override fun intercept(chain: Interceptor.Chain): okhttp3.Response { - val original = chain.request() - val newRequest = original.newBuilder() - .header("Cookie", "wor_reader_js=1") - .header("Referer", "$base/") - .header( - "Accept", - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - ) - .header("Accept-Language", "ar,en-US;q=0.7,en;q=0.3") - .header("Sec-Fetch-Dest", "document") - .header("Sec-Fetch-Mode", "navigate") - .header("Sec-Fetch-Site", "same-origin") - .header("Upgrade-Insecure-Requests", "1") - .build() - return chain.proceed(newRequest) - } - } - // ======================== Popular/Latest ======================== private fun buildPopularMangaRequest(page: Int): Request { @@ -312,27 +330,7 @@ abstract class GalaxyNovels : } } - // Step 2: fallback — the old legacy JSON endpoint. - if (novelId != null) { - try { - val resp = client.get( - "$baseUrl/wp-content/uploads/wor-reader-cache/chapters/novel-$novelId.json", - headers, - ensureSuccess = false, - ) - if (resp.isSuccessful) { - val chaptersResponse = json.decodeFromString(resp.body.string()) - Log.i(TAG, "fetchChapterList: legacy ok chapters=${chaptersResponse.chapters.size}") - return@withContext chaptersResponse.chapters.toChapterList() - } else { - Log.w(TAG, "fetchChapterList: legacy HTTP ${resp.code}") - } - } catch (e: Exception) { - Log.e(TAG, "fetchChapterList: legacy error", e) - } - } - - // Step 3: last resort — parse the detail page HTML (has the ~30 most recent chapters). + // Step 2: last resort — parse the detail page HTML (has the ~30 most recent chapters). Log.w(TAG, "fetchChapterList: falling back to HTML chapters") val htmlDoc = if (doc.select("article.wor-novel-chapter-item").isEmpty()) { client.get(baseUrl + manga.url, headers, ensureSuccess = false).asJsoup() @@ -403,25 +401,15 @@ abstract class GalaxyNovels : baseUrl + page.url } - val requestHeaders = Headers.Builder() - .add("Cookie", "wor_reader_js=1") - .add("Referer", "$baseUrl/") - .add( - "Accept", - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - ) - .add("Accept-Language", "ar,en-US;q=0.7,en;q=0.3") - .add("Sec-Fetch-Dest", "document") - .add("Sec-Fetch-Mode", "navigate") - .add("Sec-Fetch-Site", "same-origin") - .add("Upgrade-Insecure-Requests", "1") - .build() - - val response = client.get(url, requestHeaders) + // The source headers (browser UA + Cookie/Accept/Accept-Language, no Sec-Fetch-Dest/Mode) + // come from WorReaderBrowserInterceptor, which runs for every request through this client. + val response = client.get(url, headers) val doc = response.asJsoup() + // The reader renders the chapter body in `div.wor-reader-text-surface`; the older + // WordPress-style containers are kept as a fallback in case the theme changes back. val content = doc.selectFirst( - ".wor-chapter-content, .entry-content, .chapter-content, .post-content, article .content", + ".wor-reader-text-surface, .wor-chapter-content, .entry-content, .chapter-content", ) ?: doc.selectFirst("article") if (content == null) { @@ -489,11 +477,6 @@ abstract class GalaxyNovels : // ======================== Data Classes ======================== - @Serializable - class ChaptersResponse( - val chapters: List = emptyList(), - ) - /** WorReader v2 manifest: metadata + a link to the full chapter pack. */ @Serializable class ManifestResponse( From e0a3c5b0d0c21e1caddc6cc57b22940a51162ce1 Mon Sep 17 00:00:00 2001 From: hamedhani1998 Date: Sat, 26 Sep 2026 09:48:05 +0300 Subject: [PATCH 2/4] Strip GalaxyNovels' hidden anti-copy decoys from chapter text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a chapter showed sentences that are not part of the novel. The site interleaves anti-piracy notice lines *inside* the chapter body -- "اقرأ من galaxynovels.com", "لا تدعم من يسرق الروايات وينسبها لنفسه" and rotations of the same idea -- one every few paragraphs. They are plain inline elements (//) that a real browser hides, so the site itself never shows them, but a raw HTML scrape keeps them. Each carries data-wor-decoy="1" plus hidden/aria-hidden="true" and an inline style that collapses it to zero size, so on the measured chapter 17 of the 69 children of div.wor-reader-text-surface were decoys: 1323 of 7667 characters, roughly 17% of the extracted text. Dropped by attribute rather than by wording, since the site rotates the sentence. data-wor-decoy is the theme's own marker and is matched first; the accessibility attributes are kept as a fallback in case a future theme drops it. No real prose carries these attributes, so all 52 real paragraphs survive. Co-Authored-By: Claude Code --- src/ar/galaxynovels/build.gradle.kts | 2 +- .../extension/ar/galaxynovels/GalaxyNovels.kt | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/ar/galaxynovels/build.gradle.kts b/src/ar/galaxynovels/build.gradle.kts index 1a0416fd7..d7b3b9727 100644 --- a/src/ar/galaxynovels/build.gradle.kts +++ b/src/ar/galaxynovels/build.gradle.kts @@ -6,7 +6,7 @@ plugins { keiyoushi { name = "Galaxy Novels" - versionCode = 9 + versionCode = 10 contentWarning = ContentWarning.SAFE source { diff --git a/src/ar/galaxynovels/src/eu/kanade/tachiyomi/extension/ar/galaxynovels/GalaxyNovels.kt b/src/ar/galaxynovels/src/eu/kanade/tachiyomi/extension/ar/galaxynovels/GalaxyNovels.kt index c009b4589..2a406125c 100644 --- a/src/ar/galaxynovels/src/eu/kanade/tachiyomi/extension/ar/galaxynovels/GalaxyNovels.kt +++ b/src/ar/galaxynovels/src/eu/kanade/tachiyomi/extension/ar/galaxynovels/GalaxyNovels.kt @@ -52,6 +52,16 @@ abstract class GalaxyNovels : "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" const val ACCEPT_LANGUAGE = "ar,en-US;q=0.7,en;q=0.3" + + /** + * Matches the anti-piracy notice lines the site hides inside the chapter body. Each is + * marked `data-wor-decoy="1"` by the theme (and also carries `hidden aria-hidden="true"`, + * so a real browser never shows them — a plain HTML scrape keeps them). The site's own + * marker is matched first; the accessibility attributes are kept as a fallback in case a + * future theme drop it. Matching on attributes rather than wording survives the rotation. + */ + const val ANTI_COPY_SELECTOR = + "[data-wor-decoy], [data-nosnippet], [hidden][aria-hidden=true]" } // Explicit instance with ignoreUnknownKeys — the injected app-wide Json is not @@ -420,6 +430,10 @@ abstract class GalaxyNovels : content.select("script, style, ins, iframe, .ads, .ad-unit, [data-ad-position]").remove() + // The site interleaves anti-piracy notice lines inside the chapter text, one every few + // paragraphs, so they have to come out or they show up as stray sentences while reading. + content.select(ANTI_COPY_SELECTOR).remove() + return content.html() } From 00d0ae31c80e6fb9296ba4ecfd013fdb3cf63938 Mon Sep 17 00:00:00 2001 From: hamedhani1998 Date: Sat, 26 Sep 2026 12:19:54 +0300 Subject: [PATCH 3/4] Bump Galaxy Novels versionCode to 11 Co-Authored-By: Claude --- src/ar/galaxynovels/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ar/galaxynovels/build.gradle.kts b/src/ar/galaxynovels/build.gradle.kts index d7b3b9727..02af8f36d 100644 --- a/src/ar/galaxynovels/build.gradle.kts +++ b/src/ar/galaxynovels/build.gradle.kts @@ -6,7 +6,7 @@ plugins { keiyoushi { name = "Galaxy Novels" - versionCode = 10 + versionCode = 11 contentWarning = ContentWarning.SAFE source { From 929100495437a447cb2c6c618d19e0d132f7ec70 Mon Sep 17 00:00:00 2001 From: Yemen Date: Sat, 26 Sep 2026 12:54:11 +0300 Subject: [PATCH 4/4] Fix HizoManga showing no chapters hizomanga.net rejects every Madara chapter endpoint at the CDN: POST /content//ajax/chapters/ answers 403 and the legacy admin-ajax.php?action=manga_get_chapters answers 400, so the shared implementation returned an empty list. The novel page itself answers 200 and already renders the complete list into .listing-chapters_wrap .wp-manga-chapter, so parse it from there instead. Verified on a 239-chapter series (contiguous 1..239 in the initial HTML, no pagination), a 130-chapter one, a 3-chapter one, and a series with no chapters yet. Co-Authored-By: Claude --- src/ar/hizomanga/build.gradle.kts | 2 +- .../extension/ar/hizomanga/HizoManga.kt | 118 +++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/ar/hizomanga/build.gradle.kts b/src/ar/hizomanga/build.gradle.kts index 466781510..19cdb80b6 100644 --- a/src/ar/hizomanga/build.gradle.kts +++ b/src/ar/hizomanga/build.gradle.kts @@ -6,7 +6,7 @@ plugins { keiyoushi { name = "HizoManga" - versionCode = 5 + versionCode = 6 contentWarning = ContentWarning.SAFE theme = "madaranovel" diff --git a/src/ar/hizomanga/src/eu/kanade/tachiyomi/extension/ar/hizomanga/HizoManga.kt b/src/ar/hizomanga/src/eu/kanade/tachiyomi/extension/ar/hizomanga/HizoManga.kt index b4f8e9a09..1ce4cfbca 100644 --- a/src/ar/hizomanga/src/eu/kanade/tachiyomi/extension/ar/hizomanga/HizoManga.kt +++ b/src/ar/hizomanga/src/eu/kanade/tachiyomi/extension/ar/hizomanga/HizoManga.kt @@ -5,14 +5,32 @@ import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.SMangaUpdate import keiyoushi.annotation.Source import keiyoushi.network.get import okhttp3.Request +import org.jsoup.nodes.Document import org.jsoup.nodes.Element @Source abstract class HizoManga : MadaraNovel() { - override val useNewChapterEndpointDefault = true + // hizomanga.net blocks the Madara chapter endpoints at the edge (both + // POST /content//ajax/chapters/ and the legacy + // admin-ajax.php?action=manga_get_chapters answer 403/400), but the novel page itself + // returns 200 and already renders the complete chapter list into + // `.listing-chapters_wrap .wp-manga-chapter` — verified on a 239-chapter series, where + // the `
  • ` elements are contiguous 1..239 in the initial HTML with no pagination. + // So the list is read straight off the novel page instead of the ajax endpoint. + override val useNewChapterEndpointDefault = false + + /** "Chapter 12", "Ch. 12 - Title", "Episode 3: Title" — stripped from the displayed name. */ + private val chapterPrefixRegex = + Regex("""^(?:chapter|chap|ch|episode|ep)\.?\s*(\d+(?:\.\d+)?)\s*[-–—:.]?\s*""", RegexOption.IGNORE_CASE) + + /** "12 - Title" — a bare number only counts when an explicit separator follows. */ + private val chapterSeparatorRegex = Regex("""^(\d+(?:\.\d+)?)\s*[-–—:]\s*""") override fun buildPopularMangaRequest(page: Int): Request = GET("$baseUrl/page/$page/?per_page=100&s=&post_type=wp-manga", headers) @@ -33,6 +51,104 @@ abstract class HizoManga : MadaraNovel() { return MangasPage(mangas, hasNextPage) } + /** + * Reads the chapter list from the novel page's own HTML. + * + * Madara normally asks the ajax endpoint for chapters, but every chapter endpoint on this + * host is rejected at the CDN (403 for `ajax/chapters/`, 400 for `manga_get_chapters`), so + * the shared implementation would return nothing. The novel page itself always carries the + * full list, so parse it from there and keep the shared one only as a fallback. + */ + override suspend fun fetchMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate { + if (!fetchChapters) { + return SMangaUpdate( + if (fetchDetails) { + val doc = client.newCall(buildMangaDetailsRequest(manga)).execute().asJsoup() + mangaDetailsFromPage(doc, manga) + } else { + manga + }, + chapters, + ) + } + + val doc = client.newCall(buildChapterListRequest(manga)).execute().asJsoup() + val updatedManga = if (fetchDetails) mangaDetailsFromPage(doc, manga) else manga + val parsed = chaptersFromPage(doc) + return SMangaUpdate( + updatedManga, + if (reverseChapterList) parsed else parsed.reversed(), + ) + } + + /** Mirrors the theme's detail parsing, which is private and so not callable from here. */ + private fun mangaDetailsFromPage(doc: Document, manga: SManga): SManga { + val details = SManga.create().apply { + title = doc.selectFirst(".post-title h1, #manga-title h1")?.text() ?: manga.title + thumbnail_url = doc.selectFirst(".summary_image img")?.let { img -> + img.attr("data-lazy-src").ifEmpty { null } + ?: img.attr("data-src").ifEmpty { null } + ?: img.attr("src").ifEmpty { null } + } ?: manga.thumbnail_url + author = doc.selectFirst(".manga-authors")?.text() ?: manga.author + description = doc.selectFirst("div.summary__content")?.text() + ?: doc.selectFirst("#tab-manga-about")?.text() + ?: doc.selectFirst(".manga-excerpt")?.text() + ?: manga.description + genre = manga.genre + status = if ( + doc.select(".post-content_item, .post-content") + .find { it.selectFirst("h5")?.text() == "Status" } + ?.selectFirst(".summary-content")?.text() + ?.contains("Ongoing", ignoreCase = true) == true + ) { + SManga.ONGOING + } else { + SManga.COMPLETED + } + } + return details.apply { url = manga.url } + } + + /** Parses `.wp-manga-chapter` entries exactly as the theme does, but from page HTML. */ + private fun chaptersFromPage(doc: Document): List { + val total = doc.select(".wp-manga-chapter").size + val result = mutableListOf() + + doc.select(".wp-manga-chapter").forEachIndexed { index, element -> + try { + val anchor = element.selectFirst("a") ?: return@forEachIndexed + val rawName = anchor.text() + if (rawName.isBlank()) return@forEachIndexed + + val chapterUrl = anchor.attr("href") + if (chapterUrl.isBlank() || chapterUrl == "#") return@forEachIndexed + + val number = chapterPrefixRegex.find(rawName) + ?: chapterSeparatorRegex.find(rawName) + val parsedNumber = number?.groupValues?.get(1)?.toFloatOrNull() + val name = number?.let { rawName.removeRange(it.range).trim() }?.ifEmpty { null } ?: rawName + + result.add( + SChapter.create().apply { + url = chapterUrl.removePrefix(baseUrl) + this.name = if (element.className().contains("premium-block")) "🔒 $name" else name + date_upload = parseDate(element.selectFirst(".chapter-release-date")?.text().orEmpty()) + chapter_number = parsedNumber ?: (total - index).toFloat() + }, + ) + } catch (_: Exception) { + // Skip malformed entries rather than failing the whole list. + } + } + return result + } + override suspend fun fetchPageText(page: Page): String { // page.url is already an absolute URL (see MadaraNovel.getPageList) - baseUrl must not be // prepended again here, or the request resolves against a bogus "site+https" host.