diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaPlusParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaPlusParser.kt index dcde9fc51..d9a39f502 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaPlusParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaPlusParser.kt @@ -36,7 +36,7 @@ import java.util.UUID internal abstract class MangaPlusParser( context: MangaLoaderContext, source: MangaSource, - private val sourceLang: String + private val sourceLang: String, ) : MangaParser(context, source), Interceptor { override val configKeyDomain = ConfigKey.Domain("mangaplus.shueisha.co.jp") @@ -44,7 +44,7 @@ internal abstract class MangaPlusParser( override val availableSortOrders = setOf( SortOrder.POPULARITY, SortOrder.UPDATED, - SortOrder.ALPHABETICAL + SortOrder.ALPHABETICAL, ) private val extraHeaders = Headers.headersOf("Session-Token", UUID.randomUUID().toString()) @@ -65,6 +65,7 @@ internal abstract class MangaPlusParser( else -> getAllTitleList() } } + is MangaListFilter.Search -> getAllTitleList(filter.query) else -> getAllTitleList() } @@ -130,7 +131,7 @@ internal abstract class MangaPlusParser( rating = RATING_UNKNOWN, state = null, source = source, - tags = emptySet() + tags = emptySet(), ) } } @@ -161,7 +162,7 @@ internal abstract class MangaPlusParser( }, chapters = parseChapters( json.getJSONArray("chapterListGroup"), - title.getStringOrNull("language") ?: "ENGLISH" + title.getStringOrNull("language") ?: "ENGLISH", ), state = if (completed) { MangaState.FINISHED @@ -169,7 +170,7 @@ internal abstract class MangaPlusParser( MangaState.PAUSED } else { MangaState.ONGOING - } + }, ) } @@ -178,7 +179,7 @@ internal abstract class MangaPlusParser( .toJSONList() .flatMap { it.optJSONArray("firstChapterList")?.toJSONList().orEmpty() + - it.optJSONArray("lastChapterList")?.toJSONList().orEmpty() + it.optJSONArray("lastChapterList")?.toJSONList().orEmpty() } return chapterList.mapNotNull { chapter -> @@ -199,7 +200,7 @@ internal abstract class MangaPlusParser( else -> language.lowercase().toTitleCase() }, scanlator = null, - source = source + source = source, ) } } @@ -216,9 +217,9 @@ internal abstract class MangaPlusParser( val encryptionKey = mangaPage.getStringOrNull("encryptionKey") MangaPage( id = generateUid(url), - url = url + if (encryptionKey == null ) "" else "#$encryptionKey", + url = url + if (encryptionKey == null) "" else "#$encryptionKey", preview = null, - source = source + source = source, ) } } @@ -282,55 +283,55 @@ internal abstract class MangaPlusParser( class English(context: MangaLoaderContext) : MangaPlusParser( context, MangaSource.MANGAPLUSPARSER_EN, - "ENGLISH" + "ENGLISH", ) @MangaSourceParser("MANGAPLUSPARSER_ES", "MANGA Plus Spanish", "es") class Spanish(context: MangaLoaderContext) : MangaPlusParser( context, MangaSource.MANGAPLUSPARSER_ES, - "SPANISH" + "SPANISH", ) @MangaSourceParser("MANGAPLUSPARSER_FR", "MANGA Plus French", "fr") class French(context: MangaLoaderContext) : MangaPlusParser( context, MangaSource.MANGAPLUSPARSER_FR, - "FRENCH" + "FRENCH", ) @MangaSourceParser("MANGAPLUSPARSER_ID", "MANGA Plus Indonesian", "id") class Indonesian(context: MangaLoaderContext) : MangaPlusParser( context, MangaSource.MANGAPLUSPARSER_ID, - "INDONESIAN" + "INDONESIAN", ) @MangaSourceParser("MANGAPLUSPARSER_PTBR", "MANGA Plus Portuguese (Brazil)", "pt") class Portuguese(context: MangaLoaderContext) : MangaPlusParser( context, MangaSource.MANGAPLUSPARSER_PTBR, - "PORTUGUESE_BR" + "PORTUGUESE_BR", ) @MangaSourceParser("MANGAPLUSPARSER_RU", "MANGA Plus Russian", "ru") class Russian(context: MangaLoaderContext) : MangaPlusParser( context, MangaSource.MANGAPLUSPARSER_RU, - "RUSSIAN" + "RUSSIAN", ) @MangaSourceParser("MANGAPLUSPARSER_TH", "MANGA Plus Thai", "th") class Thai(context: MangaLoaderContext) : MangaPlusParser( context, MangaSource.MANGAPLUSPARSER_TH, - "THAI" + "THAI", ) @MangaSourceParser("MANGAPLUSPARSER_VI", "MANGA Plus Vietnamese", "vi") class Vietnamese(context: MangaLoaderContext) : MangaPlusParser( context, MangaSource.MANGAPLUSPARSER_VI, - "VIETNAMESE" + "VIETNAMESE", ) } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/ManhwasMen.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/ManhwasMen.kt index 970bd688e..1272c1c12 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/ManhwasMen.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/ManhwasMen.kt @@ -93,6 +93,7 @@ class ManhwasMen(context: MangaLoaderContext) : override suspend fun getDetails(manga: Manga): Manga { val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val simpleDateFormat = SimpleDateFormat("dd/MM/yyyy", sourceLocale) return manga.copy( tags = doc.body().select(".genres a").mapNotNullToSet { a -> MangaTag( @@ -116,7 +117,7 @@ class ManhwasMen(context: MangaLoaderContext) : url = url, scanlator = null, uploadDate = parseChapterDate( - SimpleDateFormat("dd/MM/yyyy", sourceLocale), + simpleDateFormat, li.selectLastOrThrow(".flex-grow-1 span").text(), ), branch = null, diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/ReaperComics.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/ReaperComics.kt new file mode 100644 index 000000000..c1cbf3c0f --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/ReaperComics.kt @@ -0,0 +1,148 @@ +package org.koitharu.kotatsu.parsers.site.en + +import okhttp3.Headers +import org.jsoup.nodes.Document +import org.koitharu.kotatsu.parsers.ErrorMessages +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.PagedMangaParser +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.network.UserAgents +import org.koitharu.kotatsu.parsers.util.* +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.* + +@MangaSourceParser("REAPERCOMICS", "ReaperComics", "en") +internal class ReaperComics(context: MangaLoaderContext) : + PagedMangaParser(context, MangaSource.REAPERCOMICS, pageSize = 30) { + + override val availableSortOrders: Set = EnumSet.of(SortOrder.UPDATED, SortOrder.ALPHABETICAL) + + override val configKeyDomain = ConfigKey.Domain("reapercomics.com") + + override val headers: Headers = Headers.Builder() + .add("User-Agent", UserAgents.CHROME_DESKTOP) + .build() + + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { + + val url = buildString { + append("https://") + append(domain) + when (filter) { + is MangaListFilter.Search -> { + throw IllegalArgumentException(ErrorMessages.SEARCH_NOT_SUPPORTED) // TODO + } + + is MangaListFilter.Advanced -> { + append("/") + if (filter.sortOrder == SortOrder.UPDATED) { + append("latest/") + } + append("comics?page=") + append(page.toString()) + } + + null -> { + append("/latest/comics?page=") + append(page.toString()) + } + } + } + + return parseMangaList(webClient.httpGet(url).parseHtml()) + } + + private fun parseMangaList(docs: Document): List { + + + return docs.select("main div.relative, main li.col-span-1").map { + val a = it.selectFirstOrThrow("a") + val url = a.attrAsAbsoluteUrl("href") + Manga( + id = generateUid(url), + url = url, + title = (it.selectFirst("p a") ?: it.selectLast("a"))?.text().orEmpty(), + altTitle = null, + publicUrl = url, + rating = RATING_UNKNOWN, + isNsfw = isNsfwSource, + coverUrl = it.selectFirstOrThrow("img").src().orEmpty(), + tags = emptySet(), + state = null, + author = null, + source = source, + ) + } + } + + override suspend fun getAvailableTags(): Set = emptySet() + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val simpleDateFormat = SimpleDateFormat("dd/MM/yyyy", sourceLocale) + return manga.copy( + description = doc.selectFirst("div.p-4 p.prose")?.html(), + state = when (doc.selectFirst("dl.mt-2 div:contains(Status) dd")?.text()?.lowercase()) { + "ongoing" -> MangaState.ONGOING + "complete" -> MangaState.FINISHED + else -> null + }, + chapters = doc.select("div.p-2 div.pb-4 ul li").mapChapters(reversed = true) { i, li -> + val a = li.selectFirstOrThrow("a") + val chapterUrl = a.attrAsAbsoluteUrl("href").toRelativeUrl(domain) + MangaChapter( + id = generateUid(chapterUrl), + name = li.selectFirst("div.truncate p.truncate")?.text().orEmpty(), + number = i + 1, + url = chapterUrl, + scanlator = null, + uploadDate = parseChapterDate( + simpleDateFormat, + li.selectFirst("div.truncate div.items-center")?.text(), + ), + branch = null, + source = source, + ) + }, + ) + } + + private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long { + val d = date?.lowercase() ?: return 0 + return when { + d.endsWith(" ago") -> parseRelativeDate(date) + else -> dateFormat.tryParse(date) + } + } + + private fun parseRelativeDate(date: String): Long { + val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0 + val cal = Calendar.getInstance() + return when { + WordSet("second").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis + WordSet("minute", "minutes").anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis + WordSet("hour", "hours").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis + WordSet("day", "days").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis + WordSet("week", "weeks").anyWordIn(date) -> cal.apply { add(Calendar.WEEK_OF_YEAR, -number) }.timeInMillis + WordSet("month", "months").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis + WordSet("year").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis + else -> 0 + } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + return doc.select("img.max-w-full").map { img -> + val url = img.src()?.toRelativeUrl(domain) ?: img.parseFailed("Image src not found") + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/ru/AComics.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/ru/AComics.kt new file mode 100644 index 000000000..32102ffc5 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/ru/AComics.kt @@ -0,0 +1,174 @@ +package org.koitharu.kotatsu.parsers.site.ru + +import androidx.collection.ArrayMap +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jsoup.nodes.Document +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.PagedMangaParser +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.util.* + +@MangaSourceParser("ACOMICS", "AComics", "ru", ContentType.COMICS) +internal class AComics(context: MangaLoaderContext) : + PagedMangaParser(context, MangaSource.ACOMICS, pageSize = 10) { + + override val availableSortOrders: Set = + EnumSet.of(SortOrder.UPDATED, SortOrder.ALPHABETICAL, SortOrder.POPULARITY) + + override val availableStates: Set = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED) + + override val configKeyDomain = ConfigKey.Domain("acomics.ru") + + init { + paginator.firstPage = 0 + searchPaginator.firstPage = 0 + context.cookieJar.insertCookies(domain, "ageRestrict=18") + } + + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { + val url = buildString { + append("https://") + append(domain) + when (filter) { + is MangaListFilter.Search -> { + if (page > 0) { + return emptyList() + } + append("/search?keyword=") + append(filter.query) + } + + is MangaListFilter.Advanced -> { + append("/comics?ratings[]=1&ratings[]=2&ratings[]=3&ratings[]=4&ratings[]=5&ratings[]=6&skip=") + append((page * 10).toString()) + append("&sort=") + append( + when (filter.sortOrder) { + SortOrder.UPDATED -> "last_update" + SortOrder.ALPHABETICAL -> "serial_name" + SortOrder.POPULARITY -> "subscr_count" + else -> "last_update" + }, + ) + + if (filter.tags.isNotEmpty()) { + append("&categories=") + append(filter.tags.joinToString(separator = ",") { it.key }) + } + + if (filter.states.isNotEmpty()) { + append("&updatable=") + append( + filter.states.oneOrThrowIfMany().let { + when (it) { + MangaState.ONGOING -> "yes" + MangaState.FINISHED -> "no" + else -> "0" + } + }, + ) + } + } + + null -> { + append("/comics?ratings[]=1&ratings[]=2&ratings[]=3&ratings[]=4&ratings[]=5&ratings[]=6&sort=last_update&skip=") + append((page * 20).toString()) + } + } + } + + return parseMangaList(webClient.httpGet(url).parseHtml()) + } + + private fun parseMangaList(docs: Document): List { + return docs.select("table.list-loadable").map { + val a = it.selectFirstOrThrow("a") + val url = a.attrAsAbsoluteUrl("href") + "/about" + Manga( + id = generateUid(url), + url = url, + title = it.selectFirstOrThrow(".title").text(), + altTitle = null, + publicUrl = url, + rating = RATING_UNKNOWN, + isNsfw = isNsfwSource, + coverUrl = it.selectFirstOrThrow("img").src().orEmpty(), + tags = emptySet(), + state = null, + author = null, + source = source, + ) + } + } + + private var tagCache: ArrayMap? = null + private val mutex = Mutex() + + override suspend fun getAvailableTags(): Set { + return getOrCreateTagMap().values.toSet() + } + + private suspend fun getOrCreateTagMap(): Map = mutex.withLock { + tagCache?.let { return@withLock it } + val tagMap = ArrayMap() + val tagElements = + webClient.httpGet("https://$domain/comics").parseHtml().requireElementById("catalog").select(" a.button") + for (el in tagElements) { + val name = el.html().substringAfterLast("") + if (name.isEmpty()) continue + tagMap[name] = MangaTag( + title = name, + key = el.attr("onclick").substringAfterLast("('").substringBefore("')"), + source = source, + ) + } + tagCache = tagMap + return@withLock tagMap + } + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val tagMap = getOrCreateTagMap() + val tags = doc.select("p.serial-about-badges .category").mapNotNullToSet { tagMap[it.text()] } + return manga.copy( + tags = tags, + description = doc.selectFirst("section.serial-about-text p")?.text(), + author = doc.selectFirst("p:contains(Автор оригинала:)")?.text()?.replace("Автор оригинала: ", ""), + chapters = listOf( + MangaChapter( + id = manga.id, + name = manga.title, + number = 1, + url = manga.url.replace("/about", "/"), + scanlator = null, + uploadDate = 0, + branch = null, + source = source, + ), + ), + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url + "1").parseHtml() + val totalPages = doc.selectFirstOrThrow("span.issueNumber").text().substringAfterLast('/').toInt() + return (1..totalPages).map { + val url = chapter.url + it + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } + + override suspend fun getPageUrl(page: MangaPage): String { + val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml() + return doc.requireElementById("mainImage").src() ?: doc.parseFailed("Image src not found") + } +}