8 Commits

11 changed files with 295 additions and 17 deletions

90
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,90 @@
# Contributing
The following is guide for creating a Kotatsu parsers. Thanks for taking the time to contribute!
## Prerequisites
Before you start, please note that the ability to use following technologies is **required**.
- Basic [Android development](https://developer.android.com/)
- [Kotlin](https://kotlinlang.org/)
- Web scraping ([JSoup](https://jsoup.org/)) or JSON API
### Tools
- [Android Studio](https://developer.android.com/studio)
- [IntelliJ IDEA](https://www.jetbrains.com/idea/) (Community edition is enough)
- Android device (or emulator)
Kotatsu parsers is not a part of Android application, but you can easily develop and test it directly inside an Android
application project and relocate it to the library project when done.
### Before you start
First, take a look at `kotatsu-parsers` project structure. Each parser is a single class that
extends `MangaParser` class and have a `MangaSourceParser` annotation.
Also pay attention on extensions in `util` package. For example, extensions from `Jsoup` file
should be used instead of existing JSoup functions because they have better nullability support
and improved error messages.
## Writing your parser
So, you want to create a parser, that will provide access to manga from a website.
First, you should explore a website for API availability.
If it does not contain any documentation about
API, [explore network requests](https://firefox-source-docs.mozilla.org/devtools-user/):
some websites use ajax.
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/DesuMeParser.kt)
of Json API usage.
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/AnibelParser.kt)
of GraphQL API usage
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/MangaTownParser.kt)
of pure HTML parsing.
If website is based on some engine it is rationally to use common base class for this one (for example, Madara wordress
theme
and the `MadaraParser` class)
### Parser class skeleton
Parser class must have exactly one primary constructor parameter of type `MangaLoaderContext` and have an
`MangaSourceParser` annotation that provides internal name, title and language of a manga source.
All functions in `MangaParser` class are documented. Pay attention to some peculiarities:
- Never hardcode domain. Specify default domain in `configKeyDomain` field and obtain an actual one using `getDomain()`.
- All ids must be unique and domain-independent. Use `generateUid` functions with relative url or some internal id which
is unique across the manga source.
- `sortOrders` set should not be empty. If your source is not support sorting, specify one most relevance value.
- If you cannot obtain direct links to pages images inside `getPages` method, it is ok to use an intermediate url
as `Page.url` and fetch a direct link at `getPageUrl` function.
- `getFaviconUrl` function is deprecated, but Kotatsu application before v4 still use it, so it is recommended to
override it too.
- You can use _asserts_ to check some optional fields. For example. `Manga.author` field is not required, but if your
source provide such information, add `assert(it != null)`. This will not have any effect on production but help to
find issues during unit testing.
- If your source website (or it's api) uses pages for pagination instead of offset you should extend `PagedMangaParser`
instead of `MangaParser`.
## Development process
During the development it is recommended (but not necessary) to write it directly
in the Kotatsu android application project. You can use `core.parser.DummyParser` class as a sandbox. `Dummy` manga
source is available in debug Kotatsu build.
Once parser is ready you can relocate your code into `kotatsu-parsers` library project in a `site` package and create a
Pull Request.
### Testing
It is recommended to run unit tests before submitting a PR.
- Temporary modify the `MangaSources` annotation class: specify your parser(s) name(s) and change mode
to `EnumSource.Mode.INCLUDE`
- Run the `MangaParserTest` (`gradlew :test --tests "org.koitharu.kotatsu.parsers.MangaParserTest"`)
- Optionally, you can run the `generateTestsReport` gradle task to get a pretty readable html report from test results.
## Help
If you need a help or have some questions, ask a community in our [Discord server](https://discord.gg/NNJ5RgVBC5).

View File

@@ -4,7 +4,7 @@ Library that provides manga sources.
[![](https://jitpack.io/v/KotatsuApp/kotatsu-parsers.svg)](https://jitpack.io/#KotatsuApp/kotatsu-parsers) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/kotatsu-parsers) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
### Usage
## Usage
1. Add it in your root build.gradle at the end of repositories:
@@ -48,4 +48,13 @@ Library that provides manga sources.
and [Non-Android](https://github.com/KotatsuApp/kotatsu-dl/blob/master/src/main/kotlin/org/koitharu/kotatsu_dl/env/MangaLoaderContextImpl.kt)
implementation examples.
Note that the `MangaSource.LOCAL` and `MangaSource.DUMMY` parsers cannot be instantiated.
Note that the `MangaSource.LOCAL` and `MangaSource.DUMMY` parsers cannot be instantiated.
## Contribution
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
## DMCA disclaimer
The developers of this application does not have any affiliation with the content available in the app. It is collecting
from the sources freely available through any web browser.

View File

@@ -69,6 +69,7 @@ class ParserProcessor(
import org.koitharu.kotatsu.parsers.model.MangaSource
@Suppress("DEPRECATION")
fun MangaSource.newParser(context: MangaLoaderContext): MangaParser = when (this) {
""".trimIndent(),
@@ -121,6 +122,7 @@ class ParserProcessor(
logger.error("Only non-abstract can be annotated with @MangaSourceParser", classDeclaration)
}
val annotation = classDeclaration.annotations.single { it.shortName.asString() == "MangaSourceParser" }
val deprecation = classDeclaration.annotations.singleOrNull { it.shortName.asString() == "Deprecated" }
val name = annotation.arguments.single { it.name?.asString() == "name" }.value as String
val title = annotation.arguments.single { it.name?.asString() == "title" }.value as String
val locale = annotation.arguments.single { it.name?.asString() == "locale" }.value as String
@@ -142,11 +144,15 @@ class ParserProcessor(
classDeclaration,
)
}
val className = classDeclaration.qualifiedName?.asString()
factoryWriter?.write("\tMangaSource.$name -> $className(context)\n")
val deprecationString = if (deprecation != null) {
val reason = deprecation.arguments
.find { it.name?.asString() == "message" }?.value?.toString() ?: "Unknown reason"
"@Deprecated(\"$reason\") "
} else ""
val localeComment = localeTitle?.toTitleCase(localeObj)?.let { " /* $it */" }.orEmpty()
sourcesWriter?.write("\t$name(\"$title\", $localeString$localeComment),\n")
sourcesWriter?.write("\t$deprecationString$name(\"$title\", $localeString$localeComment),\n")
}
}
}

View File

@@ -0,0 +1,104 @@
package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.*
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@MangaSourceParser("CLONEMANGA", "CloneManga", "en")
internal class CloneMangaParser(override val context: MangaLoaderContext) : PagedMangaParser(
MangaSource.CLONEMANGA,
pageSize = 1,
) {
override val sortOrders: Set<SortOrder> = Collections.singleton(
SortOrder.POPULARITY,
)
override val configKeyDomain = ConfigKey.Domain("manga.clone-army.org", null)
override fun getFaviconUrl(): String {
return "https://pbs.twimg.com/profile_images/458758466346029056/Ys93EANp_400x400.png"
}
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
if (query != null || page > 1) {
return emptyList()
}
val link = "https://${getDomain()}/viewer_landing.php"
val doc = context.httpGet(link).parseHtml()
val mangas = doc.getElementsByClass("comicPreviewContainer")
return mangas.mapNotNull { item ->
val attr = item.getElementsByClass("comicPreview").attr("style")
val href = item.selectFirst("a")?.attrAsAbsoluteUrl("href") ?: return@mapNotNull null
val cover = attr.substring(attr.indexOf("site/themes"), attr.indexOf(")"))
Manga(
id = generateUid(href),
title = item.selectFirst("h3")?.text() ?: return@mapNotNull null,
coverUrl = "https://${getDomain()}/$cover",
altTitle = null,
author = "Dan Kim",
rating = RATING_UNKNOWN,
url = href,
isNsfw = false,
tags = emptySet(),
state = null,
publicUrl = href.toAbsoluteUrl(getDomain()),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.publicUrl).parseHtml()
val series = doc.location()
val numChapters = Regex(
pattern = "&page=(.*)&lang=",
).findAll(
input = doc.getElementsByTag("script")[3].toString(),
)
.elementAt(3).destructured.component1()
.toInt()
val chapters = ArrayList<MangaChapter>()
for (i in 0..numChapters) {
val chapter = MangaChapter(
id = generateUid("$series&page=$i"),
name = "Chapter ${i + 1}",
number = i + 1,
url = "$series&page=$i",
scanlator = null,
branch = null,
uploadDate = 0L,
source = MangaSource.DUMMY,
)
chapters.add(chapter)
}
return manga.copy(chapters = chapters)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain())).parseHtml()
val imgUrl = doc.getElementsByClass("subsectionContainer")[0]
.selectFirst("img")
?.attrAsAbsoluteUrlOrNull("src") ?: doc.parseFailed("Something broken")
return listOf(
MangaPage(
id = generateUid(imgUrl),
url = imgUrl,
referer = imgUrl,
preview = null,
source = MangaSource.DUMMY,
),
)
}
override suspend fun getTags(): Set<MangaTag> {
return emptySet()
}
}

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@Deprecated("Permanently shutdown")
@MangaSourceParser("MANGAOWL", "MangaOwl", "en")
internal class MangaOwlParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.MANGAOWL) {

View File

@@ -15,10 +15,7 @@ import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONTo
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
import org.koitharu.kotatsu.parsers.util.json.*
import java.net.HttpURLConnection
import java.net.URLDecoder
import java.text.DateFormat
@@ -143,6 +140,9 @@ internal class RemangaParser(
)
},
chapters = chapters.mapChapters { i, jo ->
if (jo.getBooleanOrDefault("is_paid", false)) {
return@mapChapters null
}
val id = jo.getLong("id")
val name = jo.getString("name").toTitleCase(Locale.ROOT)
val publishers = jo.optJSONArray("publishers")

View File

@@ -101,7 +101,7 @@ class UnionMangasParser(override val context: MangaLoaderContext) : PagedMangaPa
url = href,
scanlator = div.selectLast("a")?.text()?.takeUnless { it == title },
uploadDate = dateFormat.tryParse(
a.nextElementSibling()?.text()?.removeSurrounding('(', ')'),
a.nextElementSibling()?.text()?.removeSurrounding("(", ")"),
),
branch = null,
source = source,

View File

@@ -14,6 +14,8 @@ import java.util.*
internal class IsekaiScanEuParser(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.ISEKAISCAN_EU, "isekaiscan.eu") {
override val datePattern = "MM/dd/yyyy"
override suspend fun getChapters(manga: Manga, doc: Document): List<MangaChapter> {
val mangaId = doc.body().requireElementById("manga-chapters-holder").attr("data-id")
val ul = context.httpPost(

View File

@@ -151,7 +151,7 @@ internal abstract class MadaraParser(
protected open suspend fun getChapters(manga: Manga, doc: Document): List<MangaChapter> {
val root2 = doc.body().selectFirstOrThrow("div.content-area")
.selectFirstOrThrow("div.c-page")
val dateFormat = SimpleDateFormat(datePattern, Locale.US)
val dateFormat = SimpleDateFormat(datePattern, sourceLocale ?: Locale.US)
return root2.select("li").asReversed().mapChapters { i, li ->
val a = li.selectFirst("a")
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
@@ -305,10 +305,13 @@ internal abstract class MadaraParser(
@MangaSourceParser("MANGAREAD", "MangaRead", "en")
class MangaRead(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAREAD, "www.mangaread.org") {
override val tagPrefix = "genres/"
override val datePattern = "dd.MM.yyyy"
}
@MangaSourceParser("MANGAWEEBS", "MangaWeebs", "en")
class MangaWeebs(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAWEEBS, "mangaweebs.in")
class MangaWeebs(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAWEEBS, "mangaweebs.in") {
override val datePattern = "dd MMMM HH:mm"
}
@MangaSourceParser("KINGMANGA", "KingManga", "en")
class KingManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.KINGMANGA, "king-manga.com") {
@@ -318,6 +321,7 @@ internal abstract class MadaraParser(
}
}
@Deprecated("Not working, unknown reason, maybe shutdown or domain expired")
@MangaSourceParser("MANGAHATACHI", "MangahaTachi", "ja")
class MangahaTachi(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.MANGAHATACHI, "mangahatachi.com")
@@ -326,7 +330,7 @@ internal abstract class MadaraParser(
class PianManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.PIANMANGA, "pianmanga.com")
@MangaSourceParser("MANGAROSIE", "MangaRosie", "en")
class MangaRosie(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAROSIE, "mangarosie.me")
class MangaRosie(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAROSIE, "mangarosie.love")
@MangaSourceParser("READMANWHA", "ReadManwha", "en")
class ReadManwha(context: MangaLoaderContext) : MadaraParser(context, MangaSource.READMANWHA, "readmanwha.net")
@@ -335,7 +339,9 @@ internal abstract class MadaraParser(
class MangaTx(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGATX, "mangatx.com")
@MangaSourceParser("MANGAEFFECT", "MangaEffect", "en")
class MangaEffect(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAEFFECT, "mangaeffect.com")
class MangaEffect(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAEFFECT, "mangaeffect.com") {
override val datePattern = "dd.MM.yyyy"
}
@MangaSourceParser("AQUAMANGA", "AquaManga", "en")
class AquaManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.AQUAMANGA, "aquamanga.com") {
@@ -357,7 +363,9 @@ internal abstract class MadaraParser(
}
@MangaSourceParser("HARIMANGA", "HariManga", "en")
class HariManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.HARIMANGA, "harimanga.com")
class HariManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.HARIMANGA, "harimanga.com") {
override val datePattern = "MM/dd/yyyy"
}
@MangaSourceParser("KISSMANGA", "KissManga", "en")
class KissManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.KISSMANGA, "kissmanga.in")
@@ -392,7 +400,9 @@ internal abstract class MadaraParser(
class ManhwaClan(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWACLAN, "manhwaclan.com")
@MangaSourceParser("MANGA_3S", "Manga3s", "en")
class Manga3s(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_3S, "manga3s.com")
class Manga3s(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_3S, "manga3s.com") {
override val tagPrefix = "manhwa-genre/"
}
@MangaSourceParser("MANHWAKOOL", "Manhwa Kool", "en")
class ManhwaKool(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWAKOOL, "manhwakool.com") {
@@ -404,6 +414,7 @@ internal abstract class MadaraParser(
@MangaSourceParser("TOPMANHUA", "Top Manhua", "en")
class TopManhua(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TOPMANHUA, "www.topmanhua.com") {
override val tagPrefix = "manhua-genre/"
override val datePattern = "MM/dd/yyyy"
}
@MangaSourceParser("X2MANGA", "X2Manga", "en")
@@ -498,6 +509,8 @@ internal abstract class MadaraParser(
@MangaSourceParser("TREE_MANGA", "Tree Manga", "en")
class TreeManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TREE_MANGA, "treemanga.com") {
override val datePattern = "MM/dd/yyyy"
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/2017/10/lgoo-treemanga-2-1.jpg"
}
@@ -532,7 +545,9 @@ internal abstract class MadaraParser(
class Manga365(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_365, "365manga.com")
@MangaSourceParser("MANGACLASH", "Mangaclash", "en")
class Mangaclash(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACLASH, "mangaclash.com")
class Mangaclash(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACLASH, "mangaclash.com") {
override val datePattern = "MM/dd/yyyy"
}
@MangaSourceParser("ZINMANGA", "ZINMANGA", "en")
class Zinmanga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ZINMANGA, "zinmanga.com")

View File

@@ -0,0 +1,51 @@
package org.koitharu.kotatsu.parsers.site.madara
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.attrAsAbsoluteUrlOrNull
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
@MangaSourceParser("MANGATREND", "MangaTrend", "ar")
internal class MangaTrendParser(context: MangaLoaderContext) :
Madara6Parser(context, MangaSource.MANGATREND, "mangaatrend.com") {
override val tagPrefix = "manga-genre/"
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/2022/08/23UOmUDN_400x400-150x150.png"
}
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
val root = body.selectFirstOrThrow(".site-content")
val postContent = root.selectFirstOrThrow(".post-content")
val tags = postContent.getElementsContainingOwnText("التصنيفات")
.firstOrNull()?.tableValue()
?.getElementsByAttributeValueContaining("href", tagPrefix)
?.mapToSet { a -> a.asMangaTag() } ?: manga.tags
return manga.copy(
largeCoverUrl = root.selectFirst("summary_image")
?.selectFirst("img[src]")
?.attrAsAbsoluteUrlOrNull("src"),
description = (root.selectFirst(".detail-content")
?: root.selectFirstOrThrow(".manga-excerpt")).html(),
author = postContent.getElementsContainingOwnText("المؤلف")
.firstOrNull()?.tableValue()?.text()?.trim(),
state = postContent.getElementsContainingOwnText("الحالة")
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
tags = tags,
isNsfw = body.hasClass("adult-content"),
chapters = chapters,
)
}
override fun String.asMangaState() = when (trim()) {
"OnGoing" -> MangaState.ONGOING
else -> null
}
}

View File

@@ -38,7 +38,7 @@ internal class MangaParserTest {
val page2 = parser.getList(page1.size, sortOrder = null, tags = null)
val intersection = page1.intersect(page2.toSet())
assert(intersection.isEmpty()) {
"Pages are intersected: " + intersection.joinToString { it.publicUrl }
"Pages are intersected by " + intersection.size
}
}