diff --git a/src/all/mangapluscreators/AndroidManifest.xml b/src/all/mangapluscreators/AndroidManifest.xml new file mode 100644 index 000000000..4fec49a4c --- /dev/null +++ b/src/all/mangapluscreators/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/all/mangapluscreators/build.gradle b/src/all/mangapluscreators/build.gradle index fdc8822c8..dd6b8de10 100644 --- a/src/all/mangapluscreators/build.gradle +++ b/src/all/mangapluscreators/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'MANGA Plus Creators by SHUEISHA' extClass = '.MangaPlusCreatorsFactory' - extVersionCode = 1 + extVersionCode = 2 } apply from: "$rootDir/common.gradle" diff --git a/src/all/mangapluscreators/src/eu/kanade/tachiyomi/extension/all/mangapluscreators/MPCUrlActivity.kt b/src/all/mangapluscreators/src/eu/kanade/tachiyomi/extension/all/mangapluscreators/MPCUrlActivity.kt new file mode 100644 index 000000000..21ca9070f --- /dev/null +++ b/src/all/mangapluscreators/src/eu/kanade/tachiyomi/extension/all/mangapluscreators/MPCUrlActivity.kt @@ -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) + } +} diff --git a/src/all/mangapluscreators/src/eu/kanade/tachiyomi/extension/all/mangapluscreators/MangaPlusCreators.kt b/src/all/mangapluscreators/src/eu/kanade/tachiyomi/extension/all/mangapluscreators/MangaPlusCreators.kt index 880dabd53..f99d48eb2 100644 --- a/src/all/mangapluscreators/src/eu/kanade/tachiyomi/extension/all/mangapluscreators/MangaPlusCreators.kt +++ b/src/all/mangapluscreators/src/eu/kanade/tachiyomi/extension/all/mangapluscreators/MangaPlusCreators.kt @@ -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() + + 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 { + // 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() + 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 { - 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 { - 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().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, + default: Int = 0, + ) : Filter.Select( + name, + options.map { it.name }.toTypedArray(), + default, + ) { + val selected: String + get() = options[state].value + } + + private class SelectFilterOption(val name: String, val value: String) } diff --git a/src/all/mangapluscreators/src/eu/kanade/tachiyomi/extension/all/mangapluscreators/MangaPlusCreatorsDto.kt b/src/all/mangapluscreators/src/eu/kanade/tachiyomi/extension/all/mangapluscreators/MangaPlusCreatorsDto.kt index 8c3ce2171..066d047f2 100644 --- a/src/all/mangapluscreators/src/eu/kanade/tachiyomi/extension/all/mangapluscreators/MangaPlusCreatorsDto.kt +++ b/src/all/mangapluscreators/src/eu/kanade/tachiyomi/extension/all/mangapluscreators/MangaPlusCreatorsDto.kt @@ -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? = emptyList(), +class MpcResponse( + val status: String, + val titles: List? = null, ) @Serializable -data class MpcEpisodesDto( - val pagination: MpcPagination? = null, - val episodeList: List? = emptyList(), -) - -@Serializable -data class MpcTitlesDto( - val pagination: MpcPagination? = null, - val titleList: List? = 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, +) + +@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, val hasNextPage: Boolean)