add Mangataro (#11177)

* Mangataro

* Refactor: Simplify search payload creation

- Use a custom serializer for search filter parameters.
- Remove redundant `.toJsonString()` calls for each filter.
- Update filter classes to use appropriate data types (Int, Int?, String?) instead of just Strings, improving type safety.
- Change `firstInstanceOrNull` to `firstInstance` for non-nullable filters.

* Refactor: Move deeplink handler

Move the deeplink handler function to a more logical position after the search parsing logic.

* MangaTaro: Implement new search method

- Add a new text search method that uses a different API endpoint. This provides more relevant results but ignores filters.
- Add a filter option to toggle between the new search and the old filter-based search.
- Exclude novels from appearing in search results and manga details.
This commit is contained in:
AwkwardPeak7 2025-10-22 10:28:49 +05:00 committed by Draff
parent fe130d5aa8
commit 3968208d9c
Signed by: Draff
GPG Key ID: E8A89F3211677653
11 changed files with 802 additions and 0 deletions

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".en.mangataro.UrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:host="mangataro.org" />
<data android:scheme="https" />
<data android:pathPattern="/manga/..*" />
<data android:host="/read/..*" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,8 @@
ext {
extName = 'MangaTaro'
extClass = '.MangaTaro'
extVersionCode = 1
isNsfw = false
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,106 @@
package eu.kanade.tachiyomi.extension.en.mangataro
import keiyoushi.utils.toJsonString
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonTransformingSerializer
@Serializable
@Suppress("unused")
class SearchPayload(
private val page: Int,
private val search: String,
@Serializable(with = StringifiedListSerializer::class)
private val years: List<Int>,
@Serializable(with = StringifiedListSerializer::class)
private val genres: List<Int>,
@Serializable(with = StringifiedListSerializer::class)
private val types: List<String>,
@Serializable(with = StringifiedListSerializer::class)
private val statuses: List<String>,
private val sort: String,
private val genreMatchMode: String,
)
class StringifiedListSerializer<T>(elementSerializer: KSerializer<T>) :
JsonTransformingSerializer<List<T>>(ListSerializer(elementSerializer)) {
override fun transformSerialize(element: JsonElement) =
JsonPrimitive(element.toJsonString())
}
@Serializable
@Suppress("unused")
class SearchQueryPayload(
val limit: Int,
val query: String,
)
@Serializable
class SearchQueryResponse(
val results: List<Manga>,
) {
@Serializable
class Manga(
val id: Int,
val slug: String,
val title: String,
val thumbnail: String,
val type: String,
val description: String,
val status: String,
)
}
@Serializable
class BrowseManga(
val id: String,
val url: String,
val title: String,
val cover: String,
val type: String,
val description: String,
val status: String,
)
@Serializable
class MangaUrl(
val id: String,
val slug: String,
)
@Serializable
class MangaDetails(
val id: Int,
val slug: String,
val title: Rendered,
val content: Rendered,
@SerialName("featured_media")
val featuredMedia: Int,
@SerialName("class_list")
private val classList: List<String>,
) {
fun getFromClassList(type: String): List<String> {
return classList.filter { it.startsWith("$type-") }
.map {
it.substringAfter("$type-")
.split("-")
.joinToString(" ") { word -> word.replaceFirstChar { it.titlecase() } }
}
}
}
@Serializable
class Thumbnail(
@SerialName("source_url")
val url: String,
)
@Serializable
class Rendered(
val rendered: String,
)

View File

@ -0,0 +1,320 @@
package eu.kanade.tachiyomi.extension.en.mangataro
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import java.util.Calendar
abstract class SelectFilter<T>(
name: String,
private val options: List<Pair<String, T>>,
) : Filter.Select<String>(
name,
options.map { it.first }.toTypedArray(),
) {
val selected get() = options[state].second
}
class CheckBoxFilter<T>(name: String, val value: T) : Filter.CheckBox(name)
abstract class CheckBoxGroup<T>(
name: String,
options: List<Pair<String, T>>,
) : Filter.Group<CheckBoxFilter<T>>(
name,
options.map { CheckBoxFilter(it.first, it.second) },
) {
val checked get() = state.filter { it.state }.map { it.value }
}
class SearchWithFilters : Filter.CheckBox("Apply filters to Text Search", false)
class TypeFilter : SelectFilter<String?>(
name = "Type",
options = listOf(
"All" to null,
"Manga" to "Manga",
"Manhwa" to "Manhwa",
"Manhua" to "Manhua",
),
)
class StatusFilter : SelectFilter<String?>(
name = "Status",
options = listOf(
"All" to null,
"Completed" to "Completed",
"Ongoing" to "Ongoing",
),
)
class YearFilter : SelectFilter<Int?>(
name = "Year",
options = buildList {
add("All" to null)
val current = Calendar.getInstance().get(Calendar.YEAR)
(current downTo 1949).mapTo(this) { it.toString() to it }
},
)
class TagFilter : CheckBoxGroup<Int>(
name = "Tags",
options = tags,
)
class TagFilterMatch : SelectFilter<String>(
name = "Tag Match",
options = listOf(
"Any" to "any",
"All" to "all",
),
)
class SortFilter(
state: Selection = Selection(0, false),
) : Filter.Sort(
name = "Sort",
values = sort.map { it.first }.toTypedArray(),
state = state,
) {
private val sortDirection get() = if (state?.ascending == true) {
"asc"
} else {
"desc"
}
val selected get() = "${sort[state?.index ?: 0].second}_$sortDirection"
companion object {
val popular = FilterList(
SortFilter(Selection(3, false)),
TagFilterMatch(),
)
val latest = FilterList(
SortFilter(Selection(0, false)),
TagFilterMatch(),
)
}
}
private val tags = listOf(
"4-Koma" to 2094,
"Abandoned Children" to 1050,
"Ability Steal" to 2386,
"Absent Parents" to 2397,
"Academy" to 4012,
"Accelerated Growth" to 999,
"Action" to 7,
"Adaptation" to 1351,
"Adapted to Anime" to 1000,
"Adapted to Drama CD" to 5072,
"Adapted to Game" to 1001,
"Adapted to Manga" to 5069,
"Adapted to Manhua" to 963,
"Adapted to Manhwa" to 1002,
"Adapted to Visual Novel" to 5070,
"Adopted Children" to 1058,
"Adult" to 88,
"Adult Cast" to 16,
"Adventure" to 12,
"Age Regression" to 2861,
"Alchemy" to 964,
"Aliens" to 965,
"Alternate World" to 966,
"Ancient Times" to 2862,
"Animals" to 1135,
"Anthology" to 2096,
"Anthropomorphic" to 93,
"Appearance Different from Actual Age" to 5071,
"Aristocracy" to 1037,
"Army" to 2387,
"Army Building" to 1005,
"Arranged Marriage" to 1047,
"Arrogant Characters" to 2401,
"Artbook" to 4551,
"Artifacts" to 4004,
"Assassins" to 2863,
"Avant Garde" to 98,
"Award Winning" to 17,
"Battle Academy" to 5279,
"Battle Competition" to 2388,
"BD" to 1137,
"Beast Companions" to 968,
"Beautiful Female Lead" to 969,
"Betrayal" to 2864,
"Blacksmith" to 971,
"Boys Love" to 80,
"Boys' Love" to 4237,
"Brotherhood" to 1038,
"Business Management" to 2398,
"Calm Protagonist" to 1006,
"Cautious Protagonist" to 2392,
"CGDCT" to 94,
"Character growth" to 4929,
"Childcare" to 51,
"Children's" to 4818,
"Cold Love Interests" to 2393,
"Cold Protagonist" to 2394,
"Combat Sports" to 92,
"Comedy" to 20,
"Cooking" to 2118,
"Crime" to 1753,
"Crossdressing" to 81,
"Cruel Characters" to 2395,
"Cunning Protagonist" to 1039,
"Cute Stuffs" to 4817,
"Dark" to 4930,
"Death of Loved Ones" to 1044,
"Delinquents" to 52,
"Demons" to 1376,
"Dense Protagonist" to 975,
"Detective" to 58,
"Devoted love interests" to 4008,
"Doujinshi" to 79,
"Dragons" to 1040,
"Drama" to 8,
"Drugs" to 1048,
"Dungeon" to 4795,
"Dungeons" to 1011,
"Ecchi" to 40,
"Educational" to 66,
"Elemental Magic" to 976,
"Elves" to 4010,
"Erotica" to 71,
"Evolution" to 4931,
"Family" to 4005,
"Fantasy" to 2,
"French" to 108,
"Full Color" to 34,
"Gag Humor" to 59,
"Game" to 36,
"Game Elements" to 979,
"Gender Bender" to 106,
"Genderswap" to 2093,
"Ghosts" to 1134,
"Girls Love" to 49,
"Gore" to 23,
"Gourmet" to 46,
"Gyaru" to 4739,
"Harem" to 64,
"Hentai" to 82,
"High Stakes Game" to 76,
"Historical" to 18,
"Horror" to 44,
"Human nonhuman relationship" to 4932,
"Idols (Female)" to 95,
"Idols (Male)" to 101,
"Indonesian" to 109,
"Isekai" to 3,
"Iyashikei" to 91,
"Josei" to 43,
"Kids" to 107,
"Kingdoms" to 4006,
"Level System" to 1020,
"Light Novel" to 55,
"Loli" to 4207,
"Lolicon" to 89,
"Long Strip" to 1172,
"Love interest falls in love first" to 4007,
"Love Polygon" to 41,
"Love Status Quo" to 100,
"Mafia" to 2061,
"Magic" to 35,
"Magical Girls" to 2095,
"Magical Sex Shift" to 68,
"Mahou Shoujo" to 97,
"Male" to 4933,
"Manga" to 14,
"Mangataro Exclusive" to 4792,
"Manhua" to 33,
"Manhwa" to 6,
"Martial Arts" to 4,
"Master-disciple relationship" to 4013,
"Mature" to 87,
"Mecha" to 74,
"Medical" to 67,
"Memoir" to 96,
"Military" to 30,
"Misunderstandings" to 1023,
"Monster Girls" to 1414,
"Monsters" to 37,
"Multiple POV" to 1024,
"Murim" to 38,
"Music" to 83,
"Mystery" to 24,
"Mythology" to 53,
"Ninja" to 4601,
"OEL" to 103,
"Office Workers" to 2062,
"Official Colored" to 4834,
"One-shot" to 48,
"Oneshot" to 4210,
"Organized Crime" to 50,
"Otaku Culture" to 70,
"Parody" to 21,
"Performing Arts" to 72,
"Pets" to 105,
"Philosophical" to 63,
"Police" to 1136,
"Post-Apocalyptic" to 1557,
"Psychological" to 26,
"Racing" to 102,
"Reincarnated in another world" to 4014,
"Reincarnation" to 5,
"Reverse Harem" to 61,
"Romance" to 29,
"Romantic Subtext" to 28,
"Samurai" to 60,
"School" to 10,
"School Life" to 73,
"Sci-Fi" to 15,
"Seinen" to 19,
"Self-Published" to 1413,
"Sexual Violence" to 4116,
"Shoujo" to 42,
"Shoujo Ai" to 90,
"Shounen" to 13,
"Shounen Ai" to 104,
"Showbiz" to 54,
"Skill books" to 4009,
"Slice of Life" to 47,
"Smut" to 85,
"Space" to 84,
"Sports" to 25,
"Strategy Game" to 77,
"Super Power" to 22,
"Superhero" to 4098,
"Supernatural" to 9,
"Survival" to 56,
"Suspense" to 75,
"Sword And Magic" to 995,
"Team Sports" to 27,
"Thai" to 591,
"Thriller" to 1341,
"Time Travel" to 32,
"Traditional Games" to 4742,
"Tragedy" to 65,
"Transported to another world" to 4011,
"Urban Fantasy" to 110,
"Vampire" to 45,
"Vampires" to 2102,
"Video Game" to 31,
"Video Games" to 1366,
"Villainess" to 62,
"Virtual Reality" to 4485,
"Visual Arts" to 57,
"Web Comic" to 1173,
"Webtoon" to 39,
"Workplace" to 69,
"Wuxia" to 1350,
"Xianxia" to 2391,
"Xuanhuan" to 962,
"Yaoi" to 86,
"Yuri" to 99,
"Zombies" to 1342,
)
private val sort = listOf(
"Latest Updates" to "post",
"Release Date" to "release",
"Title A-Z" to "title",
"Popular" to "popular",
)

View File

@ -0,0 +1,318 @@
package eu.kanade.tachiyomi.extension.en.mangataro
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
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.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.firstInstance
import keiyoushi.utils.firstInstanceOrNull
import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.jsoup.Jsoup
import rx.Observable
import java.lang.UnsupportedOperationException
import java.util.Calendar
class MangaTaro : HttpSource() {
override val name = "MangaTaro"
override val baseUrl = "https://mangataro.org"
override val lang = "en"
override val supportsLatest = true
override val client = network.cloudflareClient
override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int) =
searchMangaRequest(page, "", SortFilter.popular)
override fun popularMangaParse(response: Response) =
searchMangaParse(response)
override fun latestUpdatesRequest(page: Int) =
searchMangaRequest(page, "", SortFilter.latest)
override fun latestUpdatesParse(response: Response) =
searchMangaParse(response)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return if (query.startsWith("https://")) {
deeplinkHandler(query)
} else if (
query.isNotBlank() &&
filters.firstInstanceOrNull<SearchWithFilters>()?.state == false
) {
querySearch(query)
} else {
super.fetchSearchManga(page, query, filters)
}
}
private fun querySearch(query: String): Observable<MangasPage> {
val body = SearchQueryPayload(
query = query.trim(),
limit = 25,
).toJsonString().toRequestBody("application/json".toMediaType())
return client.newCall(POST("$baseUrl/auth/search", headers, body))
.asObservableSuccess()
.map { response ->
val data = response.parseAs<SearchQueryResponse>().results
val mangas = data.filter { it.type != "Novel" }
.map {
SManga.create().apply {
url = MangaUrl(it.id.toString(), it.slug).toJsonString()
title = it.title
thumbnail_url = it.thumbnail
description = it.description
status = when (it.status) {
"Ongoing" -> SManga.ONGOING
"Completed" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
}
MangasPage(
mangas = mangas,
hasNextPage = false,
)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val body = SearchPayload(
page = page,
search = query.trim(),
years = filters.firstInstanceOrNull<YearFilter>()
?.selected.let(::listOfNotNull),
genres = filters.firstInstanceOrNull<TagFilter>()
?.checked.orEmpty(),
types = filters.firstInstanceOrNull<TypeFilter>()
?.selected.let(::listOfNotNull),
statuses = filters.firstInstanceOrNull<StatusFilter>()
?.selected.let(::listOfNotNull),
sort = filters.firstInstance<SortFilter>().selected,
genreMatchMode = filters.firstInstance<TagFilterMatch>().selected,
).toJsonString().toRequestBody("application/json".toMediaType())
return POST("$baseUrl/wp-json/manga/v1/load", headers, body)
}
override fun getFilterList() = FilterList(
SearchWithFilters(),
Filter.Header("If unchecked, all filters will be ignored with search query"),
Filter.Header("But will give more relevant results"),
Filter.Separator(),
SortFilter(),
TypeFilter(),
StatusFilter(),
YearFilter(),
TagFilter(),
TagFilterMatch(),
)
override fun searchMangaParse(response: Response): MangasPage {
val data = response.parseAs<List<BrowseManga>>()
val mangas = data.filter { it.type != "Novel" }
.map {
SManga.create().apply {
url = MangaUrl(id = it.id, slug = it.url.toSlug()).toJsonString()
title = it.title
thumbnail_url = it.cover
description = it.description
status = when (it.status) {
"Ongoing" -> SManga.ONGOING
"Completed" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
}
return MangasPage(
mangas = mangas,
hasNextPage = data.size == 24,
)
}
private fun deeplinkHandler(url: String): Observable<MangasPage> {
val slug = url.toSlug()
return client.newCall(GET("$baseUrl/manga/$slug", headers))
.asObservableSuccess()
.map {
val document = it.asJsoup()
val id = document.body().dataset()["manga-id"]!!
val status = when (document.selectFirst(".manga-page-wrapper span.capitalize")?.text()?.lowercase()) {
"ongoing" -> SManga.ONGOING
"completed" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
if (document.selectFirst(".manga-page-wrapper span:contains(Novel)") != null) {
throw Exception("Novels are not supported")
}
id to status
}
.switchMap {
client.newCall(mangaDetailsRequest(it.first, it.second))
.asObservableSuccess()
.map(::mangaDetailsParse)
}
.map { MangasPage(listOf(it), false) }
}
override fun mangaDetailsRequest(manga: SManga): Request {
val id = manga.url.parseAs<MangaUrl>().id
return mangaDetailsRequest(id, manga.status)
}
private fun mangaDetailsRequest(id: String, status: Int): Request {
val url = baseUrl.toHttpUrl().newBuilder().apply {
addPathSegments("wp-json/wp/v2/manga")
addPathSegment(id)
fragment(status.toString())
}.build()
return GET(url, headers)
}
override fun mangaDetailsParse(response: Response): SManga {
val data = response.parseAs<MangaDetails>()
val thumbnail = getThumbnail(data.featuredMedia)
return SManga.create().apply {
url = MangaUrl(data.id.toString(), data.slug).toJsonString()
title = data.title.rendered
description = Jsoup.parseBodyFragment(data.content.rendered).wholeText()
genre = buildSet {
addAll(data.getFromClassList("tag"))
addAll(data.getFromClassList("type"))
}.joinToString()
author = data.getFromClassList("manga_author").joinToString()
status = response.request.url.fragment!!.toInt()
thumbnail_url = thumbnail
initialized = true
}
}
private fun getThumbnail(mediaId: Int): String {
val url = baseUrl.toHttpUrl().newBuilder().apply {
addPathSegments("wp-json/wp/v2/media")
addPathSegment(mediaId.toString())
}.build()
return client.newCall(GET(url, headers)).execute()
.parseAs<Thumbnail>()
.url
}
override fun getMangaUrl(manga: SManga): String {
val slug = manga.url.parseAs<MangaUrl>().slug
return "$baseUrl/manga/$slug"
}
override fun chapterListRequest(manga: SManga): Request {
return GET(getMangaUrl(manga), headers)
}
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val placeholders = listOf("", "N/A", "")
var hasScanlator = false
val chapters = document.select(".chapter-list a").map {
SChapter.create().apply {
setUrlWithoutDomain(it.absUrl("href"))
val details = it.select("> div + div > div")
name = buildString {
append(details[0].selectFirst("span")!!.ownText())
details[1].text().also { title ->
if (title !in placeholders) {
append(": ", title)
}
}
}
details[2].text().let { group ->
if (group !in placeholders) {
scanlator = group
hasScanlator = true
}
}
date_upload = details[3].text().parseRelativeDate()
}
}
if (hasScanlator) {
chapters.onEach { it.scanlator = it.scanlator ?: "\u200B" } // Insert zero-width space
}
return chapters
}
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
return document.select("img.comic-image").mapIndexed { idx, img ->
val imageUrl = when {
img.hasAttr("data-src") -> img.absUrl("data-src")
else -> img.absUrl("src")
}
Page(idx, imageUrl = imageUrl)
}
}
private fun String.toSlug() = toHttpUrl().let { url ->
val path = url.pathSegments.filter(String::isNotBlank)
if ((path.size == 2 && path[0] == "manga") || (path.size == 3 && path[0] == "read")) {
path[1]
} else {
throw Exception("Expected manga or read path, got $this")
}
}
private fun String.parseRelativeDate(): Long {
val calendar = Calendar.getInstance()
val (amount, unit) = relativeDateRegex.matchEntire(this)?.destructured
?: return 0L
when (unit) {
"h" -> calendar.add(Calendar.HOUR, -amount.toInt())
"w" -> calendar.add(Calendar.WEEK_OF_YEAR, -amount.toInt())
"mo" -> calendar.add(Calendar.MONTH, -amount.toInt())
"y" -> calendar.add(Calendar.YEAR, -amount.toInt())
}
return calendar.timeInMillis
}
private val relativeDateRegex = Regex("""(\d+)(h|w|mo|y) ago""")
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
}

View File

@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.extension.en.mangataro
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
class UrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", intent.data.toString())
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("MangaTaro", "Unable to launch activity", e)
}
finish()
exitProcess(0)
}
}