Skip to content
Merged
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
21 changes: 21 additions & 0 deletions src/ar/mtlarabic/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import io.github.keiyoushi.gradle.api.ContentWarning

plugins {
alias(ns.plugins.extension)
}

keiyoushi {
name = "MTL Arabic"
versionCode = 1
contentWarning = ContentWarning.SAFE

source {
lang = "ar"
baseUrl = "https://mtlarabic.com"
}

deeplink {
host("mtlarabic.com")
path("/..*")
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package eu.kanade.tachiyomi.novelextension.ar.mtlarabic

import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Response
import kotlin.time.Instant

/** [SManga.url] holds the numeric novel id behind this prefix. */
const val ID_PREFIX = "id-"

/** The only place the site serves real JSON; everything else is HTML with an island in it. */
val islandJson = Json { ignoreUnknownKeys = true }

@Serializable
class ListingResponse(
val items: List<ListingItem> = emptyList(),
val pagination: ListingPagination = ListingPagination(),
)

@Serializable
class ListingPagination(
val currentPage: Int = 1,
val totalPages: Int = 1,
val pageSize: Int = 12,
)

@Serializable
class ListingItem(
val id: Int = 0,
val name: String = "",
val type: String = "",
val status: String = "",
/** A bare file name; the site serves covers from /images/novels/. */
val image: String = "",
) {
fun toSManga(baseUrl: String) = SManga.create().apply {
// The numeric id is stored rather than the slug: slugs are Arabic and contain
// characters that have to be percent-encoded, and hand-encoding them is where this
// goes wrong. The id round-trips through any url and needs no encoding.
url = ID_PREFIX + id
title = name
thumbnail_url = coverUrl(baseUrl, image)
genre = type
status = mangaStatus(this@ListingItem.status)
}
}

@Serializable
class NovelDetails(
val id: Int = 0,
val slug: String = "",
val name: String = "",
val originalName: String = "",
val description: String = "",
val type: String = "",
val status: String = "",
val image: String = "",
val totalChapters: Int = 0,
)

@Serializable
class ChaptersResponse(
val chapters: List<ChapterDto> = emptyList(),
val pagination: ChapterPagination = ChapterPagination(),
)

@Serializable
class ChapterPagination(
val totalChapters: Int = 0,
val totalPages: Int = 1,
val currentPage: Int = 1,
)

@Serializable
class ChapterDto(
val number: Int = 0,
val title: String = "",
val approvalDate: String = "",
) {
fun toSChapter(novelSlug: String) = SChapter.create().apply {
url = "/$novelSlug/$number"
name = title.ifBlank { "الفصل $number" }
chapter_number = number.toFloat()
date_upload = Instant.tryParse(approvalDate)
}
}

/** The reader's JSON island, which carries the chapter body as plain text. */
@Serializable
class ChapterPage(
val content: String = "",
)

fun coverUrl(baseUrl: String, fileName: String): String {
if (fileName.isEmpty()) return ""
if (fileName.startsWith("http")) return fileName
// The listing and the detail page both send a bare file name, never a path: the cover lives at
// /images/novels/<name>. It has to be an absolute url -- a leading-slash path is not a valid
// thumbnail_url and renders as an empty cover.
return baseUrl + "/images/novels/$fileName"
}

/** The site writes exactly these two status literals and nothing else. */
fun mangaStatus(raw: String): Int = when (raw) {
"مستمرة" -> SManga.ONGOING
"مكتملة" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}

/**
* `/novels`, `/novel-details` and the reader all answer with a full HTML page whose payload is a
* JSON island in a `<script type="application/json">` tag. Parsing the response body directly
* hands the JSON decoder a document that starts with `<!DOCTYPE html>`.
*
* The reader page carries two islands, so the first one is taken: the chapter body lives in the
* earlier tag, and the later one is the novel's chapter index, which has no `content` field.
*/
private val ISLAND = Regex("""<script[^>]*type=["']application/json["'][^>]*>([\s\S]*?)</script>""", RegexOption.IGNORE_CASE)

/** Pulls the first island out of an HTML page, for use as a `String.parseAs` transform. */
fun extractIsland(html: String): String = ISLAND.find(html)?.groupValues?.get(1)
?: throw IllegalStateException("No JSON island in the page")

/** Parses a response body that is an HTML page with the JSON embedded in a script tag. */
inline fun <reified T> Response.parseAsIsland(json: Json = islandJson): T = parseAs(json) { extractIsland(it) }
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package eu.kanade.tachiyomi.novelextension.ar.mtlarabic

import eu.kanade.tachiyomi.source.NovelSource
import eu.kanade.tachiyomi.source.model.Filter
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 keiyoushi.source.KeiSource
import keiyoushi.utils.parseAs
import kotlinx.serialization.json.JsonElement
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl

private const val SORT_BY_LATEST = "latest_update"
private const val SORT_BY_CHAPTERS = "-num_chapters"

/**
* مكتبة الخيال (mtlarabic.com) — an Arabic web-novel library.
*
* A server-rendered Node app. `/novels`, `/novel-details` and the reader answer `text/html` with
* their data in a `<script type="application/json">` island, so those are read through
* [parseAsIsland] rather than parsed as bare JSON.
*
* The chapter list is the exception: the site serves `/api/novels/{id}/chapters` as real JSON,
* and that is the only endpoint that paginates past the first 100 chapters.
*/
@Source
abstract class MtlArabic :
KeiSource(),
NovelSource {

override val supportsLatest = true

private companion object {
/** The chapter endpoint's own page size; the site hardcodes 100. */
const val CHAPTERS_PER_PAGE = 100
}

// ==================== Catalogue ====================

// There is no popularity metric on the site, so "popular" is the longest novels: the listing
// sorted by chapter count, which is a standing signal of a novel people stayed with.
override suspend fun getPopularManga(page: Int): MangasPage = getNovelsPage(
listingUrl(page, SORT_BY_CHAPTERS),
)

override suspend fun getLatestUpdates(page: Int): MangasPage = getNovelsPage(
listingUrl(page, SORT_BY_LATEST),
)

override suspend fun getSearchMangaList(page: Int, query: String, filters: FilterList): MangasPage {
// The sort filter owns `sort` outright, so the base url is built without one — a second
// addQueryParameter("sort", …) on the same builder would be silently dropped.
var builder = "$baseUrl/novels".toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("q", query)

filters.forEach { filter ->
when (filter) {
is GenreFilter -> builder = builder.addQueryParameter("category", filter.selected())
is SortFilter -> builder = builder.addQueryParameter("sort", filter.selected())
else -> {}
}
}

return getNovelsPage(builder.build())
}

private fun listingUrl(page: Int, sort: String = SORT_BY_LATEST): HttpUrl = "$baseUrl/novels".toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("sort", sort)
.build()

private suspend fun getNovelsPage(url: HttpUrl): MangasPage {
val listing = client.get(url).parseAsIsland<ListingResponse>()
val pagination = listing.pagination
return MangasPage(
listing.items.map { it.toSManga(baseUrl) },
pagination.currentPage < pagination.totalPages,
)
}

// ==================== Details ====================

private fun parseDetails(details: NovelDetails) = SManga.create().apply {
url = ID_PREFIX + details.id
title = details.name
thumbnail_url = coverUrl(baseUrl, details.image)
description = details.description
// The site files the Chinese original title here; it is the only name besides the
// Arabic one, and the library has no author field of its own.
author = details.originalName
genre = details.type
status = mangaStatus(details.status)
}

private suspend fun fetchDetails(id: Int): NovelDetails = client.get(detailsUrl(id)).parseAsIsland<NovelDetails>()

private fun detailsUrl(id: Int) = "$baseUrl/novel-details?id=$id"

private fun detailsUrl(manga: SManga) = detailsUrl(manga.novelId())

override fun getMangaUrl(manga: SManga): String = detailsUrl(manga)

override suspend fun getMangaByUrl(url: HttpUrl): SManga? {
// The novel lives at /{slug} as well, and the slug is Arabic, so an id in the query is
// the only unambiguous marker. /novel-details?id= is what that link resolves to.
val id = url.queryParameter("id")?.toIntOrNull() ?: return null
return parseDetails(fetchDetails(id))
}

// ==================== Chapters ====================

override suspend fun fetchMangaUpdate(
manga: SManga,
chapters: List<SChapter>,
fetchDetails: Boolean,
fetchChapters: Boolean,
): SMangaUpdate {
// One detail request serves both halves: it carries the metadata and, through
// totalChapters, how many chapter pages there are to walk.
val details = fetchDetails(manga.novelId())

return SMangaUpdate(
if (fetchDetails) parseDetails(details) else manga,
if (fetchChapters) fetchChapterList(details) else chapters,
)
}

/**
* The detail page embeds only the first 100 chapters and ignores `?page=`, so anything past
* that is walked through the endpoint the site's own pager uses.
*/
private suspend fun fetchChapterList(details: NovelDetails): List<SChapter> {
val firstPage = fetchChapterPage(details.id, 1)
val chapters = mutableListOf<SChapter>()

firstPage.chapters.mapTo(chapters) { it.toSChapter(details.slug) }
for (page in 2..firstPage.pagination.totalPages.coerceAtLeast(1)) {
fetchChapterPage(details.id, page).chapters.mapTo(chapters) { it.toSChapter(details.slug) }
}

return chapters.sortedByDescending { it.chapter_number }
}

// The one endpoint that answers with bare JSON, so no island extraction here.
private suspend fun fetchChapterPage(id: Int, page: Int): ChaptersResponse = client.get(
"$baseUrl/api/novels/$id/chapters?page=$page&limit=$CHAPTERS_PER_PAGE&sort=asc",
).parseAs<ChaptersResponse>(islandJson)

// ==================== Reader ====================

override suspend fun getPageList(chapter: SChapter): List<Page> = listOf(Page(0, chapter.url))

override suspend fun fetchPageText(page: Page): String {
val chapter = client.get(baseUrl + page.url).parseAsIsland<ChapterPage>()
// The reader island stores the body as plain text rather than markup, one paragraph per
// line, with the lines separated by blank lines. The blanks are the site's own spacing and
// are dropped here so they don't compound with the separator below.
return chapter.content
.lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString("\n\n")
}

// ==================== Filters ====================

override fun getFilterList(data: JsonElement?): FilterList = FilterList(
GenreFilter(),
SortFilter(),
)

private fun SManga.novelId(): Int = url.removePrefix(ID_PREFIX).toInt()

/** The site's own category select, plus the "all" entry it sends as an empty value. */
private class GenreFilter :
Filter.Select<String>(
"التصنيف",
arrayOf("الكل", "تاريخي", "حضري", "خيال", "خيال علمي", "رياضي"),
) {
fun selected() = if (state == 0) "" else values[state]
}

/**
* The site renders exactly these two sorts, and nothing else: any other value is accepted in
* the query string and then quietly ignored, leaving the default listing back.
*/
private class SortFilter :
Filter.Select<String>(
"الترتيب",
arrayOf("آخر تحديث", "عدد الفصول - من أعلى لأقل"),
) {
fun selected() = if (state == 0) SORT_BY_LATEST else SORT_BY_CHAPTERS
}
}
Loading