Move MangaPlus Creators to new domain (#10337)

* start switching to new URLs for popular/manga/etc

* fix popular

* fix latest

* minor change to manga parse

* refactor popular, fix search

* fix search/popular selector

* fix chapters/pages

* fixes from debugging on android emulator

* increment ext version

* support paginated chapter lists

* break doesn't break

well, that's not true, it did work once the extension was freshly
installed. but I liked the alternative so I thought why not. can
be removed if needed

* cleanup

* add TODOs

* add intents to urls and search prefixes

support both old and new domains (since it all redirects, bless them)

* move around toSManga

pro: we get setUrlWithoutDomain
con: we lose this@<data-class-name>.title

* add filter screen

* debug search

* fix pathPattern

`..*` is the same as `.+`. however, the latter requires adding
`advancedPathPattern` instead

* what the intent: fix classdefnotfoundexception

* categorise into sections

* prefer helper functions from `utils`

* Change inline import to explicit

* inline baseUrl

* inline apiUrl

* remove superfluous header modifications

* always pass headers on new requests

* no need to convert HttpUrl to String

* make helper functions private

* use selectFirst instead of select, assert non-null

* make sub classes defined under filters private

* lint

* prefer not data but class

* Revert "break doesn't break"

This reverts commit 23b2cfe46c0f57214443e138a06cadbef0cccb61.

* lint

* better chapterNumber fail case ( -1f instead of 1f )

* lint
This commit is contained in:
nicki 2025-09-30 00:58:48 -07:00 committed by Draff
parent 0ec1a28ed7
commit 08b627c8e7
Signed by: Draff
GPG Key ID: E8A89F3211677653
5 changed files with 419 additions and 162 deletions

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.mangapluscreators.MPCUrlActivity"
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="mangaplus-creators.jp"
android:pathAdvancedPattern="/episodes/.+"
android:scheme="https" />
<data
android:host="mangaplus-creators.jp"
android:pathPattern="/titles/..*"
android:scheme="https" />
<data
android:host="mangaplus-creators.jp"
android:pathAdvancedPattern="/authors/.+"
android:scheme="https" />
<data
android:host="medibang.com"
android:pathAdvancedPattern="/mpc/episodes/.+"
android:scheme="https" />
<data
android:host="medibang.com"
android:pathAdvancedPattern="/mpc/titles/..+"
android:scheme="https" />
<data
android:host="medibang.com"
android:pathAdvancedPattern="/mpc/authors/[0-9]+"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -1,7 +1,7 @@
ext {
extName = 'MANGA Plus Creators by SHUEISHA'
extClass = '.MangaPlusCreatorsFactory'
extVersionCode = 1
extVersionCode = 2
}
apply from: "$rootDir/common.gradle"

View File

@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.extension.all.mangapluscreators
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 MPCUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
// {medibang.com/mpc,mangaplus-creators.jp}/{episodes,titles,authors}
// TODO: val pathIndex = if (intent?.data?.host?.startsWith("medibang") == true) 1 else 0
val host = intent?.data?.host ?: ""
val pathIndex = with(host) {
when {
equals("medibang.com") -> 1
else -> 0
}
}
val idIndex = pathIndex + 1
val query = when {
pathSegments[pathIndex].equals("episodes") -> {
MangaPlusCreators.PREFIX_EPISODE_ID_SEARCH + pathSegments[idIndex]
}
pathSegments[pathIndex].equals("authors") -> {
MangaPlusCreators.PREFIX_AUTHOR_ID_SEARCH + pathSegments[idIndex]
}
pathSegments[pathIndex].equals("titles") -> {
MangaPlusCreators.PREFIX_TITLE_ID_SEARCH + pathSegments[idIndex]
}
else -> null // TODO: is this required?
}
if (query != null) {
// TODO: val mainIntent = Intent().setAction("eu.kanade.tachiyomi.SEARCH").apply {
val mainIntent = Intent().apply {
setAction("eu.kanade.tachiyomi.SEARCH")
putExtra("query", query)
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("MPCUrlActivity", e.toString())
}
} else {
Log.e("MPCUrlActivity", "Missing alphanumeric ID from the URL")
}
} else {
Log.e("MPCUrlActivity", "Could not parse URI from intent $intent")
}
finish()
exitProcess(0)
}
}

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.extension.all.mangapluscreators
import eu.kanade.tachiyomi.network.GET
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
@ -8,102 +10,199 @@ 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 kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
class MangaPlusCreators(override val lang: String) : HttpSource() {
override val name = "MANGA Plus Creators by SHUEISHA"
override val baseUrl = "https://medibang.com/mpc"
override val baseUrl = "https://mangaplus-creators.jp"
private val apiUrl = "$baseUrl/api"
override val supportsLatest = true
override fun headersBuilder(): Headers.Builder = Headers.Builder()
.add("Origin", baseUrl.substringBeforeLast("/"))
.add("Referer", baseUrl)
.add("User-Agent", USER_AGENT)
private val json: Json by injectLazy()
// POPULAR Section
override fun popularMangaRequest(page: Int): Request {
val newHeaders = headersBuilder()
.set("Referer", "$baseUrl/titles/popular/?p=m")
.add("X-Requested-With", "XMLHttpRequest")
.build()
val apiUrl = "$API_URL/titles/popular/list".toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("pageSize", POPULAR_PAGE_SIZE)
.addQueryParameter("l", lang)
.addQueryParameter("p", "m")
.addQueryParameter("isWebview", "false")
.addQueryParameter("_", System.currentTimeMillis().toString())
.toString()
return GET(apiUrl, newHeaders)
val popularUrl = "$baseUrl/titles/popular/?p=m&l=$lang".toHttpUrl()
return GET(popularUrl, headers)
}
override fun popularMangaParse(response: Response): MangasPage {
val result = response.asMpcResponse()
override fun popularMangaParse(response: Response): MangasPage = parseMangasPageFromElement(
response,
"div.item-recent",
)
checkNotNull(result.titles) { EMPTY_RESPONSE_ERROR }
private fun parseMangasPageFromElement(response: Response, selector: String): MangasPage {
val result = response.asJsoup()
val titles = result.titles.titleList.orEmpty().map(MpcTitle::toSManga)
val mangas = result.select(selector).map { element ->
popularElementToSManga(element)
}
return MangasPage(titles, result.titles.pagination?.hasNextPage ?: false)
return MangasPage(mangas, false)
}
private fun popularElementToSManga(element: Element): SManga {
val titleThumbnailUrl = element.selectFirst(".image-area img")!!.attr("src")
val titleContentId = titleThumbnailUrl.toHttpUrl().pathSegments[2]
return SManga.create().apply {
title = element.selectFirst(".title-area .title")!!.text()
thumbnail_url = titleThumbnailUrl
setUrlWithoutDomain("/titles/$titleContentId")
}
}
// LATEST Section
override fun latestUpdatesRequest(page: Int): Request {
val newHeaders = headersBuilder()
.set("Referer", "$baseUrl/titles/recent/?t=episode")
.add("X-Requested-With", "XMLHttpRequest")
val apiUrl = "$apiUrl/titles/recent/".toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("l", lang)
.addQueryParameter("t", "episode")
.build()
val apiUrl = "$API_URL/titles/recent/list".toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("pageSize", POPULAR_PAGE_SIZE)
.addQueryParameter("l", lang)
.addQueryParameter("c", "episode")
.addQueryParameter("isWebview", "false")
.addQueryParameter("_", System.currentTimeMillis().toString())
.toString()
return GET(apiUrl, newHeaders)
return GET(apiUrl, headers)
}
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
override fun latestUpdatesParse(response: Response): MangasPage {
val result = response.parseAs<MpcResponse>()
val titles = result.titles.orEmpty().map { title -> title.toSManga() }
// TODO: handle last page of latest
return MangasPage(titles, result.status != "error")
}
private fun MpcTitle.toSManga(): SManga {
val mTitle = this.title
val mAuthor = this.author.name // TODO: maybe not required
return SManga.create().apply {
title = mTitle
thumbnail_url = thumbnail
setUrlWithoutDomain("/titles/${latestEpisode.titleConnectId}")
author = mAuthor
}
}
// SEARCH Section
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
// TODO: HTTPSource::fetchSearchManga is deprecated? super.getSearchManga
if (query.startsWith(PREFIX_TITLE_ID_SEARCH)) {
val titleContentId = query.removePrefix(PREFIX_TITLE_ID_SEARCH)
val titleUrl = "$baseUrl/titles/$titleContentId"
return client.newCall(GET(titleUrl, headers))
.asObservableSuccess()
.map { response ->
val result = response.asJsoup()
val bookBox = result.selectFirst(".book-box")!!
val title = SManga.create().apply {
title = bookBox.selectFirst("div.title")!!.text()
thumbnail_url = bookBox.selectFirst("div.cover img")!!.attr("data-src")
setUrlWithoutDomain(titleUrl)
}
MangasPage(listOf(title), false)
}
}
if (query.startsWith(PREFIX_EPISODE_ID_SEARCH)) {
val episodeId = query.removePrefix(PREFIX_EPISODE_ID_SEARCH)
return client.newCall(GET("$baseUrl/episodes/$episodeId", headers))
.asObservableSuccess().map { response ->
val result = response.asJsoup()
val readerElement = result.selectFirst("div[react=viewer]")!!
val dataTitle = readerElement.attr("data-title")
val dataTitleResult = dataTitle.parseAs<MpcReaderDataTitle>()
val episodeAsSManga = dataTitleResult.toSManga()
MangasPage(listOf(episodeAsSManga), false)
}
}
if (query.startsWith(PREFIX_AUTHOR_ID_SEARCH)) {
val authorId = query.removePrefix(PREFIX_AUTHOR_ID_SEARCH)
return client.newCall(GET("$baseUrl/authors/$authorId", headers))
.asObservableSuccess()
.map { response ->
val result = response.asJsoup()
val elements = result.select("#works .manga-list li .md\\:block")
val smangas = elements.map { element ->
val titleThumbnailUrl = element.selectFirst(".image-area img")!!.attr("src")
val titleContentId = titleThumbnailUrl.toHttpUrl().pathSegments[2]
SManga.create().apply {
title = element.selectFirst("p.text-white")!!.text().toString()
thumbnail_url = titleThumbnailUrl
setUrlWithoutDomain("/titles/$titleContentId")
}
}
MangasPage(smangas, false)
}
}
if (query.isNotBlank()) {
return super.fetchSearchManga(page, query, filters)
}
// nothing to search, filters active -> browsing /genres instead
// TODO: check if there's a better way (filters is independent of search but part of it)
val genreUrl = baseUrl.toHttpUrl().newBuilder()
.apply {
addPathSegment("genres")
addQueryParameter("l", lang)
filters.forEach { filter ->
when (filter) {
is SortFilter -> {
if (filter.selected.isNotEmpty()) {
addQueryParameter("s", filter.selected)
}
}
is GenreFilter -> addPathSegment(filter.selected)
else -> { /* Nothing else is supported for now */ }
}
}
}.build()
return client.newCall(GET(genreUrl, headers))
.asObservableSuccess()
.map { response ->
popularMangaParse(response)
}
}
private fun MpcReaderDataTitle.toSManga(): SManga {
val mTitle = title
return SManga.create().apply {
title = mTitle
thumbnail_url = thumbnail
setUrlWithoutDomain("/titles/$contentsId")
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val refererUrl = "$baseUrl/keywords".toHttpUrl().newBuilder()
// TODO: maybe this needn't be a new builder and just similar to `popularUrl` above?
val searchUrl = "$baseUrl/keywords".toHttpUrl().newBuilder()
.addQueryParameter("q", query)
.toString()
val newHeaders = headersBuilder()
.set("Referer", refererUrl)
.add("X-Requested-With", "XMLHttpRequest")
.addQueryParameter("s", "date")
.addQueryParameter("lang", lang)
.build()
val apiUrl = "$API_URL/search/titles".toHttpUrl().newBuilder()
.addQueryParameter("keyword", query)
.addQueryParameter("page", page.toString())
.addQueryParameter("pageSize", POPULAR_PAGE_SIZE)
.addQueryParameter("sort", "newly")
.addQueryParameter("lang", lang)
.addQueryParameter("_", System.currentTimeMillis().toString())
.toString()
return GET(apiUrl, newHeaders)
return GET(searchUrl, headers)
}
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
override fun searchMangaParse(response: Response): MangasPage = parseMangasPageFromElement(
response,
"div.item-search",
)
// MANGA Section
override fun mangaDetailsParse(response: Response): SManga {
val result = response.asJsoup()
val bookBox = result.selectFirst(".book-box")!!
@ -119,62 +218,82 @@ class MangaPlusCreators(override val lang: String) : HttpSource() {
else -> SManga.UNKNOWN
}
genre = bookBox.select("div.genre-area div.tag-genre")
.joinToString { it.text() }
.joinToString(", ") { it.text() }
thumbnail_url = bookBox.selectFirst("div.cover img")!!.attr("data-src")
}
}
// CHAPTER Section
override fun chapterListRequest(manga: SManga): Request {
val titleId = manga.url.substringAfterLast("/")
val titleContentId = (baseUrl + manga.url).toHttpUrl().pathSegments[1]
return chapterListPageRequest(1, titleContentId)
}
val newHeaders = headersBuilder()
.set("Referer", baseUrl + manga.url)
.add("X-Requested-With", "XMLHttpRequest")
.build()
val apiUrl = "$API_URL/titles/$titleId/episodes/".toHttpUrl().newBuilder()
.addQueryParameter("page", "1")
.addQueryParameter("pageSize", CHAPTER_PAGE_SIZE)
.addQueryParameter("_", System.currentTimeMillis().toString())
.toString()
return GET(apiUrl, newHeaders)
private fun chapterListPageRequest(page: Int, titleContentId: String): Request {
return GET("$baseUrl/titles/$titleContentId/?page=$page", headers)
}
override fun chapterListParse(response: Response): List<SChapter> {
val result = response.asMpcResponse()
val chapterListResponse = chapterListPageParse(response)
val chapterListResult = chapterListResponse.chapters.toMutableList()
checkNotNull(result.episodes) { EMPTY_RESPONSE_ERROR }
var hasNextPage = chapterListResponse.hasNextPage
val titleContentId = response.request.url.pathSegments[1]
var page = 1
while (hasNextPage) {
page += 1
val nextPageRequest = chapterListPageRequest(page, titleContentId)
val nextPageResponse = client.newCall(nextPageRequest).execute()
val nextPageResult = chapterListPageParse(nextPageResponse)
if (nextPageResult.chapters.isEmpty()) {
break
}
chapterListResult.addAll(nextPageResult.chapters)
hasNextPage = nextPageResult.hasNextPage
}
return result.episodes.episodeList.orEmpty()
.sortedByDescending(MpcEpisode::numbering)
.map(MpcEpisode::toSChapter)
return chapterListResult.asReversed()
}
override fun pageListRequest(chapter: SChapter): Request {
val chapterId = chapter.url.substringAfterLast("/")
val newHeaders = headersBuilder()
.set("Referer", baseUrl + chapter.url)
.add("X-Requested-With", "XMLHttpRequest")
.build()
val apiUrl = "$API_URL/episodes/pageList/$chapterId/".toHttpUrl().newBuilder()
.addQueryParameter("_", System.currentTimeMillis().toString())
.toString()
return GET(apiUrl, newHeaders)
private fun chapterListPageParse(response: Response): ChaptersPage {
val result = response.asJsoup()
val chapters = result.select(".mod-item-series").map {
element ->
chapterElementToSChapter(element)
}
val hasResult = result.select(".mod-pagination .next").isNotEmpty()
return ChaptersPage(
chapters,
hasResult,
)
}
private fun chapterElementToSChapter(element: Element): SChapter {
val episode = element.attr("href").substringAfterLast("/")
val latestUpdatedDate = element.selectFirst(".first-update")!!.text()
val chapterNumberElement = element.selectFirst(".number")!!.text()
val chapterNumber = chapterNumberElement.substringAfter("#").toFloatOrNull()
return SChapter.create().apply {
setUrlWithoutDomain("/episodes/$episode")
date_upload = CHAPTER_DATE_FORMAT.tryParse(latestUpdatedDate)
name = chapterNumberElement
chapter_number = if (chapterNumberElement == "One-shot") {
0F
} else {
chapterNumber ?: -1F
}
}
}
// PAGES & IMAGES Section
override fun pageListParse(response: Response): List<Page> {
val result = response.asMpcResponse()
checkNotNull(result.pageList) { EMPTY_RESPONSE_ERROR }
val referer = response.request.header("Referer")!!
return result.pageList.mapIndexed { i, page ->
Page(i, referer, page.publicBgImage)
val result = response.asJsoup()
val readerElement = result.selectFirst("div[react=viewer]")!!
val dataPages = readerElement.attr("data-pages")
val refererUrl = response.request.url.toString()
return dataPages.parseAs<MpcReaderDataPages>().pc.map {
page ->
Page(page.pageNo, refererUrl, page.imageUrl)
}
}
@ -191,18 +310,66 @@ class MangaPlusCreators(override val lang: String) : HttpSource() {
return GET(page.imageUrl!!, newHeaders)
}
private fun Response.asMpcResponse(): MpcResponse = use {
json.decodeFromString(body.string())
}
companion object {
private const val API_URL = "https://medibang.com/api/mpc"
private val CHAPTER_DATE_FORMAT by lazy {
SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
}
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"
private const val POPULAR_PAGE_SIZE = "30"
private const val CHAPTER_PAGE_SIZE = "200"
private const val EMPTY_RESPONSE_ERROR = "Empty response from the API. Try again later."
const val PREFIX_TITLE_ID_SEARCH = "title:"
const val PREFIX_EPISODE_ID_SEARCH = "episode:"
const val PREFIX_AUTHOR_ID_SEARCH = "author:"
}
// FILTERS Section
override fun getFilterList() = FilterList(
Filter.Separator(),
Filter.Header("NOTE: Ignored if using text search!"),
Filter.Separator(),
SortFilter(),
GenreFilter(),
Filter.Separator(),
)
private class SortFilter() : SelectFilter(
"Sort",
listOf(
SelectFilterOption("Popularity", ""),
SelectFilterOption("Date", "latest_desc"),
SelectFilterOption("Likes", "like_desc"),
),
0,
)
private class GenreFilter() : SelectFilter(
"Genres",
listOf(
SelectFilterOption("Fantasy", "fantasy"),
SelectFilterOption("Action", "action"),
SelectFilterOption("Romance", "romance"),
SelectFilterOption("Horror", "horror"),
SelectFilterOption("Slice of Life", "slice_of_life"),
SelectFilterOption("Comedy", "comedy"),
SelectFilterOption("Sports", "sports"),
SelectFilterOption("Sci-Fi", "sf"),
SelectFilterOption("Mystery", "mystery"),
SelectFilterOption("Others", "others"),
),
0,
)
private abstract class SelectFilter(
name: String,
private val options: List<SelectFilterOption>,
default: Int = 0,
) : Filter.Select<String>(
name,
options.map { it.name }.toTypedArray(),
default,
) {
val selected: String
get() = options[state].value
}
private class SelectFilterOption(val name: String, val value: String)
}

View File

@ -1,68 +1,51 @@
package eu.kanade.tachiyomi.extension.all.mangapluscreators
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MpcResponse(
@SerialName("mpcEpisodesDto") val episodes: MpcEpisodesDto? = null,
@SerialName("mpcTitlesDto") val titles: MpcTitlesDto? = null,
val pageList: List<MpcPage>? = emptyList(),
class MpcResponse(
val status: String,
val titles: List<MpcTitle>? = null,
)
@Serializable
data class MpcEpisodesDto(
val pagination: MpcPagination? = null,
val episodeList: List<MpcEpisode>? = emptyList(),
)
@Serializable
data class MpcTitlesDto(
val pagination: MpcPagination? = null,
val titleList: List<MpcTitle>? = emptyList(),
)
@Serializable
data class MpcPagination(
val page: Int,
val maxPage: Int,
) {
val hasNextPage: Boolean
get() = page < maxPage
}
@Serializable
data class MpcTitle(
@SerialName("titleId") val id: String,
class MpcTitle(
val title: String,
val thumbnailUrl: String,
) {
fun toSManga(): SManga = SManga.create().apply {
title = this@MpcTitle.title
thumbnail_url = thumbnailUrl
url = "/titles/$id"
}
}
val thumbnail: String,
@SerialName("is_one_shot") val isOneShot: Boolean,
val author: MpcAuthorDto,
@SerialName("latest_episode") val latestEpisode: MpcLatestEpisode,
)
@Serializable
data class MpcEpisode(
@SerialName("episodeId") val id: String,
@SerialName("episodeTitle") val title: String,
val numbering: Int,
val oneshot: Boolean = false,
val publishDate: Long,
) {
fun toSChapter(): SChapter = SChapter.create().apply {
name = if (oneshot) "One-shot" else title
date_upload = publishDate
url = "/episodes/$id"
}
}
class MpcAuthorDto(
val name: String,
)
@Serializable
data class MpcPage(val publicBgImage: String)
class MpcLatestEpisode(
@SerialName("title_connect_id") val titleConnectId: String,
)
@Serializable
class MpcReaderDataPages(
val pc: List<MpcReaderPage>,
)
@Serializable
class MpcReaderPage(
@SerialName("page_no") val pageNo: Int,
@SerialName("image_url") val imageUrl: String,
)
@Serializable
class MpcReaderDataTitle(
val title: String,
val thumbnail: String,
@SerialName("is_oneshot") val isOneShot: Boolean,
@SerialName("contents_id") val contentsId: String,
)
class ChaptersPage(val chapters: List<SChapter>, val hasNextPage: Boolean)