Compare commits

..

No commits in common. 'master' and 'feature/ci' have entirely different histories.

@ -3,18 +3,12 @@ root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
indent_style = tab
max_line_length = 120
insert_final_newline = true
trim_trailing_whitespace = true
# noinspection EditorConfigKeyCorrectness
disabled_rules = no-wildcard-imports, no-unused-imports
tab_width = 4
insert_final_newline = false
disabled_rules=no-wildcard-imports,no-unused-imports
[*.{kt,kts}]
[{*.kt,*.kts}]
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
[*.md]
indent_size = 2
trim_trailing_whitespace = false

@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: ⚠️ Application issue
url: https://github.com/KotatsuApp/Kotatsu/issues/new/choose
- name: ⚠️Application issue
url: https://github.com/nv95/Kotatsu/issues/new/choose
about: Issues and requests about the app itself should be opened in the Kotatsu repository instead

@ -1,6 +1,6 @@
name: 🐞 Issue report
description: Report a source issue with a source
labels: [ bug ]
description: Report a source issue in Kotatsu
labels: [bug]
body:
- type: input
@ -24,9 +24,28 @@ body:
1. First step
2. Second step
3. Issue here
Please use English language
validations:
required: false
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
placeholder: |
Example:
"This should happen..."
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
placeholder: |
Example:
"This happened instead..."
validations:
required: true
- type: input
id: kotatsu-version
@ -48,7 +67,7 @@ body:
placeholder: |
Example: "Android 12"
validations:
required: false
required: true
- type: textarea
id: other-details
@ -61,6 +80,15 @@ body:
id: acknowledgements
attributes:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true
- label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/nv95/Kotatsu/issues/new/choose).
required: true
- label: I will fill out all of the requested information in this form.
required: true

@ -1,6 +1,6 @@
name: ⭐ Feature request
description: Suggest a feature to improve a source
labels: [ feature request ]
labels: [feature request]
body:
- type: textarea
@ -11,7 +11,6 @@ body:
placeholder: |
Example:
"It should work like this..."
Please use English language
validations:
required: true
@ -26,6 +25,15 @@ body:
id: acknowledgements
attributes:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/nv95/Kotatsu/issues/new/choose).
required: true
- label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true

@ -1,6 +1,6 @@
name: 🗑 Source removal request
description: Scanlators can request their site to be removed
labels: [ source removal ]
labels: [source removal]
body:
- type: input
@ -15,7 +15,7 @@ body:
- type: textarea
id: other-details
attributes:
label: Other details (reason for removal, etc)
label: Other details
placeholder: |
Additional details and attachments.
@ -25,7 +25,9 @@ body:
label: Requirements
description: Your request will be denied if you don't meet these requirements.
options:
- label: Proof of ownership of the website is sent to a Kotatsu [Discord server](https://discord.gg/NNJ5RgVBC5) or [Telegram community](https://t.me/kotatsuapp)
- label: Proof of ownership/intent to remove sent to a Kotatsu Discord server mod via DM
required: true
- label: Site only hosts content scanlated by the group and not stolen from other scanlators or official releases (i.e., not an aggregator site)
required: true
- label: Site is not infested with user-hostile features (e.g., invasive or malicious ads)
required: true

@ -1,11 +1,8 @@
name: 🌐 Source request
description: Suggest a new source for Kotatsu
labels: [ source request ]
labels: [source request]
body:
- type: markdown
attributes:
value: Please specify source **name** and **language** in the issue title
- type: input
id: name
attributes:
@ -44,10 +41,15 @@ body:
id: acknowledgements
attributes:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a title with source name.
required: true
- label: I have checked that the source does not already exist on the app.
required: true
- label: I have checked that the source does not already exist by searching the [GitHub repository](https://github.com/KotatsuApp/kotatsu-parsers) and verified it does not appear in the code base.
- label: I have checked that the source does not already exist by searching the [GitHub repository](https://github.com/nv95/kotatsu-parsers) and verified it does not appear in the code base.
required: true
- label: I will fill out all of the requested information in this form.
required: true

@ -1 +0,0 @@
total: 1256

@ -1,25 +0,0 @@
name: Check & Test latest parsers
on:
push:
branches:
- master
jobs:
check-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository 🌏
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up enviroment 🔧
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: '21'
distribution: 'temurin'
- name: Set up Gradle 📦
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
- name: Compile parsers 🚀
run: ./gradlew compileKotlin

@ -1,29 +1,28 @@
name: Parsers test for PRs
name: Parsers test
on:
workflow_dispatch:
pull_request:
branches: [ "master" ]
paths:
- 'src/main/kotlin/org/koitharu/kotatsu/parsers/**'
- 'src/main/kotlin/org/koitharu/kotatsu/parsers/site'
permissions:
contents: read
jobs:
build-and-test:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository 🌏
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up enviroment 🔧
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: '21'
java-version: '11'
distribution: 'temurin'
- name: Set up Gradle 📦
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
- name: Compile parsers 🚀
run: ./gradlew compileKotlin
cache: 'gradle'
- run: ./gradlew :test --tests "org.koitharu.kotatsu.parsers.MangaParserTest"
- uses: actions/upload-artifact@v3
with:
name: Report
path: build/reports/tests/test

20
.gitignore vendored

@ -4,7 +4,6 @@
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
.idea/**/copilot
# Generated files
.idea/**/contentModel.xml
@ -17,7 +16,6 @@
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
.idea/**/Project_Default.xml
# Gradle
.idea/**/gradle.xml
@ -27,13 +25,10 @@
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
.idea/deviceManager.xml
.idea/.name
.idea/artifacts
.idea/compiler.xml
.idea/jarRepositories.xml
.idea/modules.xml
.idea/ktlint-plugin.xml
.idea/*.iml
.idea/modules
*.iml
@ -74,25 +69,10 @@ fabric.properties
.gradle/
build/
bin/
.idea/**/misc.xml
.idea/**/vcs.xml
.idea/**/ktlint.xml
.idea/codeStyles/
.idea/kotlinc.xml
src/test/resources/cookies.txt
local.properties
.kotlin/
!/.idea/kotlin-statistics.xml
.idea/**/discord.xml
.idea/**/migrations.xml
.idea/**/runConfigurations.xml
.idea/**/AndroidProjectSystem.xml
.idea/caches/deviceStreaming.xml
/.idea/copilot.data.migration.agent.xml
/.idea/copilot.data.migration.ask.xml
/.idea/copilot.data.migration.ask2agent.xml
/.idea/copilot.data.migration.edit.xml

5
.idea/.gitignore vendored

@ -1,8 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
# GitHub Copilot persisted chat sessions
/copilot/chatSessions
.name
deviceManager.xml

@ -1,96 +0,0 @@
# Contributing
The following is a guide for creating Kotatsu parsers. Thanks for taking the time to contribute!
## Prerequisites
Before you start, please note that the ability to use the 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 are not a part of the 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 the `kotatsu-parsers` project structure. Each parser is a single class that
extends the `MangaParser` class and has a `MangaSourceParser` annotation.
Also, pay attention to extensions in the `util` package. For example, extensions from the `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 to learn about 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/ru/DesuMeParser.kt)
of Json API usage.
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/be/AnibelParser.kt)
of GraphQL API usage
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/MangaTownParser.kt)
of pure HTML parsing.
If the website is based on some engine it is rationally to use a common base class for this one (for example, Madara
Wordpress theme and the `MadaraParser` class)
### Parser class skeleton
The parser class must have exactly one primary constructor parameter of type `MangaLoaderContext` and have an
`MangaSourceParser` annotation that provides the internal name, title, and language of a manga source.
All members of the `MangaParser` class are documented. Pay attention to some peculiarities:
- Never hardcode domain. Specify the default domain in the `configKeyDomain` field and obtain an actual one using
`domain`.
- All IDs must be unique and domain-independent. Use `generateUid` functions with a relative URL or some internal id
that is unique across the manga source.
- The `availableSortOrders` set should not be empty. If your source does not support sorting, specify one most relevant
value.
- If you cannot obtain direct links to page images inside the `getPages` method, it is ok to use an intermediate URL
as `Page.url` and fetch a direct link in the `getPageUrl` function.
- You can use _asserts_ to check some optional fields. For example, the `Manga.author` field is not required, but if
your source provides this information, add `assert(it != null)`. This will not have any effect on production but help
to find issues during unit testing.
- Your parser may also implement the `Interceptor` interface for additional manipulation of all network requests and
responses, including image loading.
- If your source website (or its API) uses pages for pagination instead of offset you should extend `PagedMangaParser`
instead of `MangaParser`.
- If your source website (or its API) does not provide pagination (has only one page of content) you should extend
`SinglePageMangaParser` instead of `MangaParser` or `PagedMangaParser`.
![parser_classes.png](docs/parser_classes.png)
## Development process
During the development, it is recommended (but not necessary) to write it directly
in the Kotatsu Android application project. You can use the `core.parser.DummyParser` class as a sandbox. The `Dummy`
manga source is available in the debug Kotatsu build.
Once the parser is ready you can relocate your code into the `kotatsu-parsers` library project in a `site` package and
create a Pull Request.
### Testing
It is recommended that unit tests be run 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 help or have some questions, ask a community in our [Telegram chat](https://t.me/kotatsuapp)
or [Discord server](https://discord.gg/NNJ5RgVBC5).

@ -1,13 +1,12 @@
# Kotatsu parsers
This library provides a collection of manga parsers for convenient access manga available on the web. It can be used in
JVM and Android applications.
Library that provides manga sources.
![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C) [![](https://jitpack.io/v/KotatsuApp/kotatsu-parsers.svg)](https://jitpack.io/#KotatsuApp/kotatsu-parsers) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
[![](https://jitpack.io/v/nv95/kotatsu-parsers.svg)](https://jitpack.io/#nv95/kotatsu-parsers) ![Kotlin](https://img.shields.io/github/languages/top/nv95/kotatsu-parsers) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
## Usage
### Usage
1. Add it to your root build.gradle at the end of repositories:
1. Add it in your root build.gradle at the end of repositories:
```groovy
allprojects {
@ -23,52 +22,28 @@ JVM and Android applications.
For Java/Kotlin project:
```groovy
dependencies {
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsers_version")
implementation("com.github.nv95:kotatsu-parsers:$parsers_version")
}
```
For Android project:
```groovy
dependencies {
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsers_version") {
implementation("com.github.nv95:kotatsu-parsers:$parsers_version") {
exclude group: 'org.json', module: 'json'
}
}
```
Versions are available on [JitPack](https://jitpack.io/#KotatsuApp/kotatsu-parsers)
When used in Android
projects, [core library desugaring](https://developer.android.com/studio/write/java8-support#library-desugaring) with
the [NIO specification](https://developer.android.com/studio/write/java11-nio-support-table) should be enabled to
support Java 8+ features.
See for versions at [JitPack](https://jitpack.io/#nv95/kotatsu-parsers)
3. Usage in code
```kotlin
val parser = mangaLoaderContext.newParserInstance(MangaParserSource.MANGADEX)
val parser = MangaSource.MANGADEX.newParser(mangaLoaderContext)
```
`mangaLoaderContext` is an implementation of the `MangaLoaderContext` class.
See examples
of [Android](https://github.com/KotatsuApp/Kotatsu/blob/devel/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt)
and [Non-Android](https://github.com/KotatsuApp/kotatsu-dl/blob/master/src/main/kotlin/org/koitharu/kotatsu/dl/parsers/MangaLoaderContextImpl.kt)
implementation.
## Projects that use the library
- [Kotatsu](https://github.com/KotatsuApp/Kotatsu)
- [Doki](https://github.com/DokiTeam/Doki)
- [kotatsu-dl](https://github.com/KotatsuApp/kotatsu-dl)
- [Shirizu (WIP)](https://github.com/ztimms73/shirizu)
- [OtakuWorld](https://github.com/jakepurple13/OtakuWorld)
## Contribution
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
## DMCA disclaimer
See [Android](https://github.com/nv95/Kotatsu/blob/devel/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt) and [Non-Android](https://github.com/nv95/kotatsu-dl/blob/master/src/main/kotlin/org/koitharu/kotatsu_dl/env/MangaLoaderContextImpl.kt) implementation examples.
The developers of this application have no affiliation with the content available in the app. It is collected from
sources freely available through any web browser.
Note that the `MangaSource.LOCAL` and `MangaSource.DUMMY` parsers cannot be instantiated.

@ -0,0 +1,68 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'com.google.devtools.ksp'
id 'maven-publish'
}
group = 'org.koitharu'
version = '1.0'
test {
useJUnitPlatform()
}
compileKotlin {
kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += [
'-opt-in=kotlin.RequiresOptIn',
'-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi',
]
}
}
compileTestKotlin {
kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += [
'-opt-in=kotlin.RequiresOptIn',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi',
]
}
}
kotlin {
sourceSets {
main.kotlin.srcDirs += 'build/generated/ksp/main/kotlin'
}
}
afterEvaluate {
publishing {
publications {
mavenJava(MavenPublication) {
from components.java
}
}
}
}
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okio:okio:3.1.0'
implementation 'org.jsoup:jsoup:1.15.1'
implementation 'org.json:json:20220320'
implementation 'androidx.collection:collection-ktx:1.2.0'
ksp project(':kotatsu-parsers-ksp')
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2'
testImplementation 'io.webfolder:quickjs:1.1.0'
}

@ -1,64 +0,0 @@
import tasks.ReportGenerateTask
plugins {
`java-library`
`maven-publish`
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.ksp)
}
group = "org.koitharu"
version = "1.0"
tasks.test {
useJUnitPlatform()
}
ksp {
arg("summaryOutputDir", "${projectDir}/.github")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=kotlin.contracts.ExperimentalContracts",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi",
)
}
}
kotlin {
jvmToolchain(8)
explicitApiWarning()
sourceSets["main"].kotlin.srcDirs("build/generated/ksp/main/kotlin")
}
publishing {
publications {
create<MavenPublication>("mavenJava") {
from(components["java"])
}
}
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.okhttp)
implementation(libs.okio)
implementation(libs.json)
implementation(libs.androidx.collection)
api(libs.jsoup)
ksp(project(":kotatsu-parsers-ksp"))
testImplementation(libs.junit.api)
testImplementation(libs.junit.engine)
testImplementation(libs.junit.params)
testRuntimeOnly(libs.junit.launcher)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.quickjs)
}
tasks.register<ReportGenerateTask>("generateTestsReport")

@ -1,17 +0,0 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
kotlin {
jvmToolchain(8)
}
dependencies {
implementation(libs.korte)
implementation(libs.simplexml)
implementation(libs.kotlinx.coroutines.core)
}

Binary file not shown.

@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
buildSrc/gradlew vendored

@ -1,251 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

@ -1,7 +0,0 @@
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}

@ -1,71 +0,0 @@
package tasks
import korlibs.template.Template
import kotlinx.coroutines.runBlocking
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction
import org.simpleframework.xml.Serializer
import org.simpleframework.xml.core.Persister
import tasks.model.TestCase
import tasks.model.TestSuite
import java.io.File
import kotlin.math.roundToInt
open class ReportGenerateTask : DefaultTask() {
@TaskAction
fun execute() {
val reportsRoot = File(project.rootDir, "build/test-results/test")
val outputRoot = File(project.rootDir, "build/test-results-html")
outputRoot.deleteRecursively()
outputRoot.mkdir()
for (file in checkNotNull(reportsRoot.listFiles()) {
"No files found in $reportsRoot"
}) {
if (file.isDirectory) {
continue
}
val report = makeReport(file)
val output = File(outputRoot, file.nameWithoutExtension + ".htm")
output.writeText(report)
println("Report generated: ${output.absolutePath}")
}
}
private fun makeReport(file: File): String {
val serializer: Serializer = Persister()
val testSuite = serializer.read(TestSuite::class.java, file)
val templateText = javaClass.classLoader.getResourceAsStream("report.html")?.use {
it.bufferedReader().readText()
}
val results = LinkedHashMap<String, LinkedHashMap<String, TestCase>>()
val tests = LinkedHashSet<String>()
for (case in testSuite.testCases) {
if (!case.isValid()) {
continue
}
tests.add(case.testName)
val map = results.getOrPut(case.source) { LinkedHashMap() }
val oldValue = map.put(case.testName, case)
check(oldValue == null) { "Check failed: $oldValue" }
}
val failPercent = (testSuite.failures.toDouble() / testSuite.tests * 100.0).roundToInt()
val errorPercent = (testSuite.errors.toDouble() / testSuite.tests * 100.0).roundToInt()
return runBlocking {
val template = Template(requireNotNull(templateText))
template(
mapOf(
"testSuite" to testSuite,
"tests" to tests,
"results" to results,
"success_percent" to 100 - (failPercent + errorPercent),
"error_percent" to errorPercent,
"fail_percent" to failPercent,
"success" to testSuite.tests - (testSuite.failures + testSuite.errors),
),
)
}
}
}

@ -1,24 +0,0 @@
package tasks.model
import org.simpleframework.xml.Attribute
import org.simpleframework.xml.Root
import org.simpleframework.xml.Text
@Root(name = "failure", strict = false)
class Failure {
@JvmField
@field:Attribute(name = "message")
var message: String = ""
@JvmField
@field:Attribute(name = "type")
var type: String = ""
@JvmField
@field:Text
var text: String = ""
fun textHtml(): String {
return text.replace("\n", "<br>")
}
}

@ -1,34 +0,0 @@
package tasks.model
import org.simpleframework.xml.Attribute
import org.simpleframework.xml.Element
import org.simpleframework.xml.Root
@Root(name = "testcase", strict = false)
class TestCase {
@JvmField
@field:Attribute(name = "name")
var name: String = ""
@JvmField
@field:Attribute(name = "time")
var time: Float = 0f
@JvmField
@field:Element(name = "failure", required = false)
var failure: Failure? = null
val index by lazy {
name.split('|').getOrNull(0)?.toIntOrNull() ?: 0
}
val testName by lazy {
name.split('|').getOrNull(1).orEmpty()
}
val source by lazy {
name.split('|').getOrNull(2).orEmpty()
}
fun isValid() = name.count { it == '|' } == 2
}

@ -1,27 +0,0 @@
package tasks.model
import org.simpleframework.xml.Attribute
import org.simpleframework.xml.ElementList
import org.simpleframework.xml.Root
@Root(name = "testsuite", strict = false)
class TestSuite {
@JvmField
@field:Attribute(name = "name")
var name: String = ""
@JvmField
@field:Attribute(name = "tests")
var tests: Int = 0
@JvmField
@field:Attribute(name = "failures")
var failures: Int = 0
@JvmField
@field:Attribute(name = "errors")
var errors: Int = 0
@field:ElementList(entry = "testcase", inline = true)
lateinit var testCases: List<TestCase>
}

@ -1,106 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1" name="viewport">
<title>{{ testSuite.name }}</title>
<!-- CSS only -->
<link crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css"
integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" rel="stylesheet">
<!-- JavaScript Bundle with Popper -->
<script crossorigin="anonymous"
integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3"
src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
</head>
<body class="py-4">
<div class="container">
<h1 class="h2">{{ testSuite.name }}</h1>
<div class="progress mt-4">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ success_percent }}%">
{{ success }} ({{ success_percent }}%)
</div>
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ fail_percent }}%">
{{ testSuite.failures }} ({{ fail_percent }}%)
</div>
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ error_percent }}%">
{{ testSuite.errors }} ({{ error_percent }}%)
</div>
</div>
<table class="table table-hover">
<thead class="sticky-top bg-body">
<tr>
<th scope="col">Source</th>
{% for test in tests %}
<th class="text-center" scope="col" style="min-width: 5em;">{{ test }}</th>
{% endfor %}
</tr>
</thead>
{% for name, cases in results %}
<tr>
<th scope="row">{{ name }}</th>
{% for test in tests %}
{% set case = cases[test] %}
{% if case.failure == null %}
<td class="table-success text-center">
<i data-feather="check"></i>
</td>
{% else %}
{% if case.failure.type == 'java.lang.AssertionError' %}
<td class="table-warning text-center" data-bs-target="#failure_{{ case.hashCode }}"
data-bs-toggle="modal" style="cursor: pointer;">
<i data-feather="alert-triangle"></i>
</td>
{% elseif case.failure.type == 'java.net.SocketTimeoutException' or case.failure.type ==
'java.net.UnknownHostException' %}
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
data-bs-toggle="modal" style="cursor: pointer;">
<i data-feather="power"></i>
</td>
{% elseif case.failure.type == 'org.koitharu.kotatsu.parsers.CloudFlareProtectedException' %}
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
data-bs-toggle="modal" style="cursor: pointer;">
<i data-feather="shield"></i>
</td>
{% elseif case.failure.type == 'org.koitharu.kotatsu.parsers.exception.AuthRequiredException' %}
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
data-bs-toggle="modal" style="cursor: pointer;">
<i data-feather="user-x"></i>
</td>
{% else %}
<td class="table-danger text-center" data-bs-target="#failure_{{ case.hashCode }}"
data-bs-toggle="modal" style="cursor: pointer;">
<i data-feather="x"></i>
</td>
{% endif %}
<!--suppress HtmlUnknownTag -->
<div class="modal fade" id="failure_{{ case.hashCode }}" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ case.testName }} failed</h5>
<button aria-label="Close" class="btn-close" data-bs-dismiss="modal"
type="button"></button>
</div>
<div class="modal-body font-monospace lh-sm bg-light" style="font-size: 0.7em;">
{{ case.failure.textHtml()|raw }}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal" type="button">Close</button>
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</table>
</div>
<script>
feather.replace()
</script>
</body>
</html>

@ -1,804 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<gaphor xmlns="https://gaphor.org/model" xmlns:Core="https://gaphor.org/modelinglanguage/Core" xmlns:UML="https://gaphor.org/modelinglanguage/UML" xmlns:general="https://gaphor.org/modelinglanguage/general" version="4" gaphor-version="3.1.0">
<model>
<Core:StyleSheet id="58d6989a-66f8-11ec-b4c8-0456e5e540ed"/>
<UML:Package id="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed">
<name>
<val>Новая модель</val>
</name>
<ownedDiagram>
<reflist>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</reflist>
</ownedDiagram>
<ownedType>
<reflist>
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
<ref refid="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="198a3108-f5be-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="32081654-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</ownedType>
<packagedElement>
<reflist>
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
<ref refid="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="198a3108-f5be-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="32081654-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="25a17c58-f5be-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="41318a02-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</packagedElement>
</UML:Package>
<UML:Diagram id="58d6c536-66f8-11ec-b4c8-0456e5e540ed">
<element>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</element>
<name>
<val>Новая диаграмма</val>
</name>
<ownedPresentation>
<reflist>
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
<ref refid="a3018fc2-f5bd-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="ad4d2aae-f5bd-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="198aace6-f5be-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="320868c0-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="b760bcd4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="bff32eae-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="d5113ca4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="e8042ad8-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="531831f2-f5c0-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="5ccae8d4-f5c0-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="b6e0240e-f5bd-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="254e40f6-f5be-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="2bb6e87a-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="40e36d2c-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="bcc07c64-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="f6b48e4c-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="f89ba010-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="fabe0540-f5bf-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="6f993af6-f5c0-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</ownedPresentation>
</UML:Diagram>
<UML:Class id="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8">
<clientDependency>
<reflist>
<ref refid="25a17c58-f5be-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</clientDependency>
<comment>
<reflist>
<ref refid="bff31afe-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</comment>
<isAbstract>
<val>1</val>
</isAbstract>
<name>
<val>AbstractMangaParser</val>
</name>
<owningPackage>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</owningPackage>
<package>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</package>
<presentation>
<reflist>
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
</reflist>
</presentation>
<specialization>
<reflist>
<ref refid="b969dac6-f5bd-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="2c236356-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</specialization>
</UML:Class>
<UML:ClassItem id="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8">
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 405.16796875, 388.8671875)</val>
</matrix>
<top-left>
<val>(0.0, 0.0)</val>
</top-left>
<width>
<val>158.0</val>
</width>
<height>
<val>60.0</val>
</height>
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<show_attributes>
<val>0</val>
</show_attributes>
<show_operations>
<val>0</val>
</show_operations>
<subject>
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
</subject>
</UML:ClassItem>
<UML:Class id="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8">
<comment>
<reflist>
<ref refid="d5112a70-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</comment>
<generalization>
<reflist>
<ref refid="b969dac6-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</generalization>
<isAbstract>
<val>1</val>
</isAbstract>
<name>
<val>PagedMangaParser</val>
</name>
<owningPackage>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</owningPackage>
<package>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</package>
<presentation>
<reflist>
<ref refid="a3018fc2-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
</UML:Class>
<UML:ClassItem id="a3018fc2-f5bd-11ef-9ec2-4cbb5880a0b8">
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 476.3368367667698, 525.76953125)</val>
</matrix>
<top-left>
<val>(0.0, 0.0)</val>
</top-left>
<width>
<val>142.0</val>
</width>
<height>
<val>60.0</val>
</height>
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<show_attributes>
<val>0</val>
</show_attributes>
<show_operations>
<val>0</val>
</show_operations>
<subject>
<ref refid="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</subject>
</UML:ClassItem>
<UML:Class id="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8">
<comment>
<reflist>
<ref refid="e80418f4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</comment>
<generalization>
<reflist>
<ref refid="2c236356-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</generalization>
<isAbstract>
<val>1</val>
</isAbstract>
<name>
<val>SinglePageMangaParser</val>
</name>
<owningPackage>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</owningPackage>
<package>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</package>
<presentation>
<reflist>
<ref refid="ad4d2aae-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
</UML:Class>
<UML:ClassItem id="ad4d2aae-f5bd-11ef-9ec2-4cbb5880a0b8">
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 405.16796875, 627.46875)</val>
</matrix>
<top-left>
<val>(0.0, 0.0)</val>
</top-left>
<width>
<val>175.0</val>
</width>
<height>
<val>60.0</val>
</height>
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<show_attributes>
<val>0</val>
</show_attributes>
<show_operations>
<val>0</val>
</show_operations>
<subject>
<ref refid="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</subject>
</UML:ClassItem>
<UML:GeneralizationItem id="b6e0240e-f5bd-11ef-9ec2-4cbb5880a0b8">
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<horizontal>
<val>False</val>
</horizontal>
<orthogonal>
<val>False</val>
</orthogonal>
<subject>
<ref refid="b969dac6-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</subject>
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 499.2109069824219, 463.45703125)</val>
</matrix>
<points>
<val>[(28.486861756586336, 62.3125), (25.111328125, -14.58984375)]</val>
</points>
<head-connection>
<ref refid="a3018fc2-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</head-connection>
<tail-connection>
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
</tail-connection>
</UML:GeneralizationItem>
<UML:Generalization id="b969dac6-f5bd-11ef-9ec2-4cbb5880a0b8">
<general>
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
</general>
<presentation>
<reflist>
<ref refid="b6e0240e-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
<specific>
<ref refid="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</specific>
</UML:Generalization>
<UML:Interface id="198a3108-f5be-11ef-9ec2-4cbb5880a0b8">
<name>
<val>MangaParser</val>
</name>
<owningPackage>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</owningPackage>
<package>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</package>
<presentation>
<reflist>
<ref refid="198aace6-f5be-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
<supplierDependency>
<reflist>
<ref refid="25a17c58-f5be-11ef-9ec2-4cbb5880a0b8"/>
<ref refid="41318a02-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</supplierDependency>
</UML:Interface>
<UML:InterfaceItem id="198aace6-f5be-11ef-9ec2-4cbb5880a0b8">
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 278.00391387939453, 232.92578125)</val>
</matrix>
<top-left>
<val>(0.0, 0.0)</val>
</top-left>
<width>
<val>105.0</val>
</width>
<height>
<val>80.0</val>
</height>
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<show_attributes>
<val>0</val>
</show_attributes>
<show_operations>
<val>0</val>
</show_operations>
<subject>
<ref refid="198a3108-f5be-11ef-9ec2-4cbb5880a0b8"/>
</subject>
<folded>
<val>0</val>
</folded>
</UML:InterfaceItem>
<UML:InterfaceRealizationItem id="254e40f6-f5be-11ef-9ec2-4cbb5880a0b8">
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<horizontal>
<val>False</val>
</horizontal>
<orthogonal>
<val>False</val>
</orthogonal>
<subject>
<ref refid="25a17c58-f5be-11ef-9ec2-4cbb5880a0b8"/>
</subject>
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 306.1445007324219, 270.0625)</val>
</matrix>
<points>
<val>[(55.866059373910275, 42.86328125), (164.5765002560883, 118.8046875)]</val>
</points>
<head-connection>
<ref refid="198aace6-f5be-11ef-9ec2-4cbb5880a0b8"/>
</head-connection>
<tail-connection>
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
</tail-connection>
</UML:InterfaceRealizationItem>
<UML:InterfaceRealization id="25a17c58-f5be-11ef-9ec2-4cbb5880a0b8">
<client>
<reflist>
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
</reflist>
</client>
<owningPackage>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</owningPackage>
<presentation>
<reflist>
<ref refid="254e40f6-f5be-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
<supplier>
<reflist>
<ref refid="198a3108-f5be-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</supplier>
</UML:InterfaceRealization>
<UML:GeneralizationItem id="2bb6e87a-f5bf-11ef-9ec2-4cbb5880a0b8">
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<horizontal>
<val>False</val>
</horizontal>
<orthogonal>
<val>False</val>
</orthogonal>
<subject>
<ref refid="2c236356-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</subject>
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 436.2929382324219, 439.1913757324219)</val>
</matrix>
<points>
<val>[(20.37646032737257, 188.27737426757812), (18.488327026367188, 9.675811767578125)]</val>
</points>
<head-connection>
<ref refid="ad4d2aae-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</head-connection>
<tail-connection>
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
</tail-connection>
</UML:GeneralizationItem>
<UML:Generalization id="2c236356-f5bf-11ef-9ec2-4cbb5880a0b8">
<general>
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
</general>
<presentation>
<reflist>
<ref refid="2bb6e87a-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
<specific>
<ref refid="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</specific>
</UML:Generalization>
<UML:Class id="32081654-f5bf-11ef-9ec2-4cbb5880a0b8">
<clientDependency>
<reflist>
<ref refid="41318a02-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</clientDependency>
<name>
<val>MangaParserWrapper</val>
</name>
<note>
<val></val>
</note>
<owningPackage>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</owningPackage>
<package>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</package>
<presentation>
<reflist>
<ref refid="320868c0-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
</UML:Class>
<UML:ClassItem id="320868c0-f5bf-11ef-9ec2-4cbb5880a0b8">
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 128.5008992667698, 410.48990205860804)</val>
</matrix>
<top-left>
<val>(0.0, 0.0)</val>
</top-left>
<width>
<val>158.0</val>
</width>
<height>
<val>60.0</val>
</height>
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<show_attributes>
<val>0</val>
</show_attributes>
<show_operations>
<val>0</val>
</show_operations>
<subject>
<ref refid="32081654-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</subject>
</UML:ClassItem>
<UML:InterfaceRealizationItem id="40e36d2c-f5bf-11ef-9ec2-4cbb5880a0b8">
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<horizontal>
<val>False</val>
</horizontal>
<orthogonal>
<val>False</val>
</orthogonal>
<subject>
<ref refid="41318a02-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</subject>
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 306.0585632324219, 249.69920349121094)</val>
</matrix>
<points>
<val>[(11.759223915218172, 63.22657775878906), (-98.55766396565207, 160.7906985673971)]</val>
</points>
<head-connection>
<ref refid="198aace6-f5be-11ef-9ec2-4cbb5880a0b8"/>
</head-connection>
<tail-connection>
<ref refid="320868c0-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</tail-connection>
</UML:InterfaceRealizationItem>
<UML:InterfaceRealization id="41318a02-f5bf-11ef-9ec2-4cbb5880a0b8">
<client>
<reflist>
<ref refid="32081654-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</client>
<owningPackage>
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
</owningPackage>
<presentation>
<reflist>
<ref refid="40e36d2c-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
<supplier>
<reflist>
<ref refid="198a3108-f5be-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</supplier>
</UML:InterfaceRealization>
<UML:Comment id="b760ac44-f5bf-11ef-9ec2-4cbb5880a0b8">
<body>
<val>Used for providing external api. Do not use this class directly</val>
</body>
<presentation>
<reflist>
<ref refid="b760bcd4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
</UML:Comment>
<UML:CommentItem id="b760bcd4-f5bf-11ef-9ec2-4cbb5880a0b8">
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 108.0561294327963, 550.1347579956054)</val>
</matrix>
<top-left>
<val>(0.0, 0.0)</val>
</top-left>
<width>
<val>183.21868896484375</val>
</width>
<height>
<val>91.23829650878906</val>
</height>
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<subject>
<ref refid="b760ac44-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</subject>
</UML:CommentItem>
<UML:CommentLineItem id="bcc07c64-f5bf-11ef-9ec2-4cbb5880a0b8">
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<horizontal>
<val>False</val>
</horizontal>
<orthogonal>
<val>False</val>
</orthogonal>
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 549.205520203852, 278.05499559311954)</val>
</matrix>
<points>
<val>[(-349.5400462886338, 192.4349064654885), (-349.5400462886338, 272.0797624024858)]</val>
</points>
<tail-connection>
<ref refid="b760bcd4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</tail-connection>
</UML:CommentLineItem>
<UML:Comment id="bff31afe-f5bf-11ef-9ec2-4cbb5880a0b8">
<annotatedElement>
<reflist>
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
</reflist>
</annotatedElement>
<body>
<val>Extend this class if your manga source provides standard limit-offset based lists (get manga list by offset)</val>
</body>
<presentation>
<reflist>
<ref refid="bff32eae-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
</UML:Comment>
<UML:CommentItem id="bff32eae-f5bf-11ef-9ec2-4cbb5880a0b8">
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 673.0610499890082, 367.0515553989646)</val>
</matrix>
<top-left>
<val>(0.0, 0.0)</val>
</top-left>
<width>
<val>228.8028016098773</val>
</width>
<height>
<val>88.0</val>
</height>
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<subject>
<ref refid="bff31afe-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</subject>
</UML:CommentItem>
<UML:Comment id="d5112a70-f5bf-11ef-9ec2-4cbb5880a0b8">
<annotatedElement>
<reflist>
<ref refid="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</annotatedElement>
<body>
<val>Extend this class if your manga source provides paged-based lists (get manga list by page number)</val>
</body>
<presentation>
<reflist>
<ref refid="d5113ca4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
</UML:Comment>
<UML:CommentItem id="d5113ca4-f5bf-11ef-9ec2-4cbb5880a0b8">
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 666.7924311664914, 507.7539062499999)</val>
</matrix>
<top-left>
<val>(0.0, 0.0)</val>
</top-left>
<width>
<val>214.34368896484375</val>
</width>
<height>
<val>88.0</val>
</height>
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<subject>
<ref refid="d5112a70-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</subject>
</UML:CommentItem>
<UML:Comment id="e80418f4-f5bf-11ef-9ec2-4cbb5880a0b8">
<annotatedElement>
<reflist>
<ref refid="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</annotatedElement>
<body>
<val>Extend this class if your manga source does not provide pagination (all manga provided in one list)</val>
</body>
<presentation>
<reflist>
<ref refid="e8042ad8-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
</UML:Comment>
<UML:CommentItem id="e8042ad8-f5bf-11ef-9ec2-4cbb5880a0b8">
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 666.7924311664914, 560.9671898788581)</val>
</matrix>
<top-left>
<val>(0.0, 58.00435704705592)</val>
</top-left>
<width>
<val>263.9307954323941</val>
</width>
<height>
<val>78.01706672440287</val>
</height>
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<subject>
<ref refid="e80418f4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</subject>
</UML:CommentItem>
<UML:CommentLineItem id="f6b48e4c-f5bf-11ef-9ec2-4cbb5880a0b8">
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<horizontal>
<val>False</val>
</horizontal>
<orthogonal>
<val>False</val>
</orthogonal>
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 561.8951626340418, 549.6101338901756)</val>
</matrix>
<points>
<val>[(56.44167413272805, 7.038279316310902), (104.89726853244963, 8.304008355003589)]</val>
</points>
<head-connection>
<ref refid="a3018fc2-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</head-connection>
<tail-connection>
<ref refid="d5113ca4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</tail-connection>
</UML:CommentLineItem>
<UML:CommentLineItem id="f89ba010-f5bf-11ef-9ec2-4cbb5880a0b8">
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<horizontal>
<val>False</val>
</horizontal>
<orthogonal>
<val>False</val>
</orthogonal>
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 559.3873501340418, 413.0007588901755)</val>
</matrix>
<points>
<val>[(3.7806186159582467, 0.0), (113.67369985496646, 1.6012844908540842)]</val>
</points>
<head-connection>
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
</head-connection>
<tail-connection>
<ref refid="bff32eae-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</tail-connection>
</UML:CommentLineItem>
<UML:CommentLineItem id="fabe0540-f5bf-11ef-9ec2-4cbb5880a0b8">
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<horizontal>
<val>False</val>
</horizontal>
<orthogonal>
<val>False</val>
</orthogonal>
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 522.3600063840418, 652.6882588901756)</val>
</matrix>
<points>
<val>[(57.80796236595825, 5.29182139794003), (144.43242478244963, 5.657840086725969)]</val>
</points>
<head-connection>
<ref refid="ad4d2aae-f5bd-11ef-9ec2-4cbb5880a0b8"/>
</head-connection>
<tail-connection>
<ref refid="e8042ad8-f5bf-11ef-9ec2-4cbb5880a0b8"/>
</tail-connection>
</UML:CommentLineItem>
<general:Box id="531831f2-f5c0-11ef-9ec2-4cbb5880a0b8">
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 375.05802564891326, 349.05453145170736)</val>
</matrix>
<top-left>
<val>(0.0, 3.15625)</val>
</top-left>
<width>
<val>590.6594026101285</val>
</width>
<height>
<val>368.44140625</val>
</height>
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
</general:Box>
<UML:Comment id="5ccad4ca-f5c0-11ef-9ec2-4cbb5880a0b8">
<body>
<val>To create your own parser you have to extends one of these classes</val>
</body>
<presentation>
<reflist>
<ref refid="5ccae8d4-f5c0-11ef-9ec2-4cbb5880a0b8"/>
</reflist>
</presentation>
</UML:Comment>
<UML:CommentItem id="5ccae8d4-f5c0-11ef-9ec2-4cbb5880a0b8">
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 756.725301794198, 225.57697659840966)</val>
</matrix>
<top-left>
<val>(0.0, 0.0)</val>
</top-left>
<width>
<val>208.99212646484375</val>
</width>
<height>
<val>73.47482464883183</val>
</height>
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<subject>
<ref refid="5ccad4ca-f5c0-11ef-9ec2-4cbb5880a0b8"/>
</subject>
</UML:CommentItem>
<UML:CommentLineItem id="6f993af6-f5c0-11ef-9ec2-4cbb5880a0b8">
<diagram>
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
</diagram>
<horizontal>
<val>False</val>
</horizontal>
<orthogonal>
<val>False</val>
</orthogonal>
<matrix>
<val>(1.0, 0.0, 0.0, 1.0, 943.6141683885666, 419.2772168262177)</val>
</matrix>
<points>
<val>[(-27.404961772030788, -67.06643537451032), (-27.404961772030788, -120.2254155789762)]</val>
</points>
<head-connection>
<ref refid="531831f2-f5c0-11ef-9ec2-4cbb5880a0b8"/>
</head-connection>
<tail-connection>
<ref refid="5ccae8d4-f5c0-11ef-9ec2-4cbb5880a0b8"/>
</tail-connection>
</UML:CommentLineItem>
</model>
</gaphor>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

@ -1,15 +1 @@
## Following this blog:
# https://proandroiddev.com/how-we-reduced-our-gradle-build-times-by-over-80-51f2b6d6b05b
kotlin.code.style=official
systemProp.org.gradle.unsafe.configuration-cache=false
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=4096m -XX:+UseParallelGC
org.gradle.configureondemand=true
org.gradle.configuration-cache.problems=warn
## Use these flags on local machine for faster build time
# org.gradle.caching=true
# org.gradle.configuration-cache=true
# org.gradle.vfs.watch=true
# org.gradle.parallel=true
# org.gradle.workers.max=8
# org.gradle.configuration-cache.max-problems=8

@ -1,34 +0,0 @@
[versions]
kotlin = "2.2.10"
ksp = "2.2.10-2.0.2"
coroutines = "1.10.2"
junit = "5.10.1"
okhttp = "5.1.0"
okio = "3.16.0"
json = "20240303"
androidx-collection = "1.5.0"
jsoup = "1.21.2"
quickjs = "1.1.0"
korte = "4.0.10"
simplexml = "2.7.1"
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
[libraries]
ksp-symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher" }
junit-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }
junit-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" }
junit-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
json = { module = "org.json:json", version.ref = "json" }
androidx-collection = { module = "androidx.collection:collection", version.ref = "androidx-collection" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
quickjs = { module = "io.webfolder:quickjs", version.ref = "quickjs" }
korte = { module = "com.soywiz.korlibs.korte:korte-jvm", version.ref = "korte" }
simplexml = { module = "org.simpleframework:simple-xml", version.ref = "simplexml" }

Binary file not shown.

@ -1,7 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

49
gradlew vendored

@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -15,8 +15,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@ -57,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -82,11 +80,13 @@ do
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -114,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@ -133,29 +133,22 @@ location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@ -200,28 +193,18 @@ if "$cygwin" || "$msys" ; then
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.

41
gradlew.bat vendored

@ -13,10 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@ -27,8 +25,7 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@ -43,13 +40,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
if "%ERRORLEVEL%" == "0" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
@ -59,34 +56,32 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal

@ -1,2 +0,0 @@
jdk:
- openjdk17

@ -0,0 +1,7 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
}
dependencies {
implementation 'com.google.devtools.ksp:symbol-processing-api:1.6.21-1.0.5'
}

@ -1,11 +0,0 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
kotlin {
jvmToolchain(8)
}
dependencies {
implementation(libs.ksp.symbol.processing.api)
}

@ -7,7 +7,6 @@ import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSVisitorVoid
import com.google.devtools.ksp.validate
import java.io.File
import java.io.Writer
import java.util.*
@ -16,18 +15,19 @@ class ParserProcessor(
private val logger: KSPLogger,
private val options: Map<String, String>,
) : SymbolProcessor {
private val availableLocales = Locale.getAvailableLocales().toSet()
private val sourceNamePattern = Regex("[A-Z_][A-Z0-9_]{3,}")
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation("org.koitharu.kotatsu.parsers.MangaSourceParser")
val symbols = resolver
.getSymbolsWithAnnotation("org.koitharu.kotatsu.parsers.MangaSourceParser")
val ret = symbols.filterNot { it.validate() }.toList()
if (!symbols.iterator().hasNext()) {
return ret
}
val dependencies = Dependencies.ALL_FILES
val factoryFile =
try {
val factoryFile = try {
codeGenerator.createNewFile(
dependencies = dependencies,
packageName = "org.koitharu.kotatsu.parsers",
@ -37,8 +37,7 @@ class ParserProcessor(
logger.warn(e.toString(), null)
null
}
val sourcesFile =
try {
val sourcesFile = try {
codeGenerator.createNewFile(
dependencies = dependencies,
packageName = "org.koitharu.kotatsu.parsers.model",
@ -48,12 +47,11 @@ class ParserProcessor(
logger.warn(e.toString(), null)
null
}
val totalCount = sourcesFile?.writer().use { sourcesWriter ->
sourcesFile?.writer().use { sourcesWriter ->
factoryFile?.writer().use { factoryWriter ->
writeContent(sourcesWriter, factoryWriter, symbols)
}
}
writeSummary(totalCount)
return ret
}
@ -61,18 +59,17 @@ class ParserProcessor(
sourcesWriter: Writer?,
factoryWriter: Writer?,
symbols: Sequence<KSAnnotated>,
): Int {
) {
if (sourcesWriter == null && factoryWriter == null) {
return 0
return
}
factoryWriter?.write(
"""
package org.koitharu.kotatsu.parsers
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.core.MangaParserWrapper
import org.koitharu.kotatsu.parsers.model.MangaSource
internal fun MangaParserSource.newParser(context: MangaLoaderContext): MangaParser = when (this) {
fun MangaSource.newParser(context: MangaLoaderContext): MangaParser = when (this) {
""".trimIndent(),
)
@ -80,67 +77,54 @@ class ParserProcessor(
"""
package org.koitharu.kotatsu.parsers.model
public enum class MangaParserSource(
public val title: String,
public val locale: String,
public val contentType: ContentType,
public val isBroken: Boolean,
): MangaSource {
enum class MangaSource(
val title: String,
val locale: String?,
) {
LOCAL("Local", null),
""".trimIndent(),
)
val visitor = ParserVisitor(sourcesWriter, factoryWriter)
val totalCount = symbols
symbols
.filter { it is KSClassDeclaration && it.validate() }
.onEach { it.accept(visitor, Unit) }
.count()
.forEach { it.accept(visitor, Unit) }
factoryWriter?.write(
"""
}.let {
MangaSource.LOCAL -> throw NotImplementedError("Local manga parser is not supported")
MangaSource.DUMMY -> throw NotImplementedError("Dummy manga parser cannot be instantiated")
}.also {
require(it.source == this) {
"Cannot instantiate manga parser: ${'$'}name mapped to ${'$'}{it.source}"
}
MangaParserWrapper(it)
}
""".trimIndent(),
)
sourcesWriter?.write(
"""
DUMMY("Dummy", null),
;
}
""".trimIndent(),
)
return totalCount
}
private fun writeSummary(totalCount: Int) {
val file = File(options["summaryOutputDir"] ?: return, "summary.yaml")
file.writeText("total: $totalCount")
}
private inner class ParserVisitor(
private val sourcesWriter: Writer?,
private val factoryWriter: Writer?,
) : KSVisitorVoid() {
private val titles = HashMap<String, String>()
override fun visitClassDeclaration(
classDeclaration: KSClassDeclaration,
data: Unit,
) {
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
if (classDeclaration.classKind != ClassKind.CLASS || classDeclaration.isAbstract()) {
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 isBroken = classDeclaration.annotations.any { it.shortName.asString() == "Broken" }
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
val type = annotation.arguments.single { it.name?.asString() == "type" }.value
val localeString = "\"$locale\""
val localeString = if (locale.isEmpty()) "null" else "\"$locale\""
val localeObj = if (locale.isEmpty()) null else Locale(locale)
val localeTitle = localeObj?.getDisplayLanguage(localeObj)
if (localeObj != null && localeObj !in availableLocales) {
@ -158,29 +142,11 @@ class ParserProcessor(
classDeclaration,
)
}
val className = checkNotNull(classDeclaration.qualifiedName?.asString()) { "Class name is null" }
val prevTitleClass = titles.put(title, className)
if (prevTitleClass != null) {
logger.warn("Source title duplication: \"$title\" is assigned to both $prevTitleClass and $className")
}
factoryWriter?.write("\tMangaParserSource.$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 className = classDeclaration.qualifiedName?.asString()
factoryWriter?.write("\tMangaSource.$name -> $className(context)\n")
val localeComment = localeTitle?.toTitleCase(localeObj)?.let { " /* $it */" }.orEmpty()
sourcesWriter?.write(
"\t$deprecationString$name(\"$title\", $localeString$localeComment, $type, $isBroken),\n",
)
sourcesWriter?.write("\t$name(\"$title\", $localeString$localeComment),\n")
}
}
}

@ -0,0 +1,22 @@
pluginManagement {
plugins {
id 'com.google.devtools.ksp' version '1.6.21-1.0.5'
id 'org.jetbrains.kotlin.jvm' version '1.6.21'
}
repositories {
gradlePluginPortal()
google()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'kotatsu-parsers'
include 'kotatsu-parsers-ksp'

@ -1,18 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "kotatsu-parsers"
include("kotatsu-parsers-ksp")

@ -1,14 +0,0 @@
package org.koitharu.kotatsu.parsers
/**
* Annotate [MangaParser] implementation to mark this parser as broken instead of removing it
*/
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
internal annotation class Broken(
/**
* Reason why this parser is broken
*/
val message: String = "",
)

@ -1,18 +0,0 @@
package org.koitharu.kotatsu.parsers
public object ErrorMessages {
public const val FILTER_MULTIPLE_STATES_NOT_SUPPORTED: String = "Multiple states are not supported by this source"
public const val FILTER_MULTIPLE_GENRES_NOT_SUPPORTED: String = "Multiple genres are not supported by this source"
public const val FILTER_MULTIPLE_CONTENT_RATING_NOT_SUPPORTED: String =
"Multiple Content ratings are not supported by this source"
public const val FILTER_MULTIPLE_CONTENT_TYPES_NOT_SUPPORTED: String =
"Multiple Content types are not supported by this source"
public const val FILTER_MULTIPLE_DEMOGRAPHICS_NOT_SUPPORTED: String =
"Multiple Demographics are not supported by this source"
public const val FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED: String =
"Filtering by both genres and locale is not supported by this source"
public const val FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED: String =
"Filtering by both genres and states is not supported by this source"
public const val SEARCH_NOT_SUPPORTED: String = "Search is not supported by this source"
}

@ -11,4 +11,4 @@ package org.koitharu.kotatsu.parsers
@SinceKotlin("1.3")
@RequiresOptIn
@MustBeDocumented
public annotation class InternalParsersApi
annotation class InternalParsersApi()

@ -1,78 +1,157 @@
package org.koitharu.kotatsu.parsers
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Response
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.exception.GraphQLException
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.LinkResolver
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseJson
import java.util.*
public abstract class MangaLoaderContext {
abstract class MangaLoaderContext {
public abstract val httpClient: OkHttpClient
protected abstract val httpClient: OkHttpClient
public abstract val cookieJar: CookieJar
abstract val cookieJar: CookieJar
public fun newParserInstance(source: MangaParserSource): MangaParser = source.newParser(this)
public fun newLinkResolver(link: HttpUrl): LinkResolver = LinkResolver(this, link)
public fun newLinkResolver(link: String): LinkResolver = newLinkResolver(link.toHttpUrl())
public open fun encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
public open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data)
/**
* Do a GET http request to specific url
* @param url
* @param headers an additional headers for request, may be null
*/
suspend fun httpGet(url: String, headers: Headers? = null): Response {
val request = Request.Builder()
.get()
.url(url)
if (headers != null) {
request.headers(headers)
}
return httpClient.newCall(request.build()).await().ensureSuccess()
}
public open fun getPreferredLocales(): List<Locale> = listOf(Locale.getDefault())
/**
* Do a HEAD http request to specific url
* @param url
* @param headers an additional headers for request, may be null
*/
suspend fun httpHead(url: String, headers: Headers? = null): Response {
val request = Request.Builder()
.head()
.url(url)
if (headers != null) {
request.headers(headers)
}
return httpClient.newCall(request.build()).await().ensureSuccess()
}
/**
* Execute JavaScript code and return result
* @param script JavaScript source code
* @return execution result as string, may be null
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param form payload as key=>value map
* @param headers an additional headers for request, may be null
*/
@Deprecated("Provide a base url")
public abstract suspend fun evaluateJs(script: String): String?
suspend fun httpPost(
url: String,
form: Map<String, String>,
headers: Headers? = null,
): Response {
val body = FormBody.Builder()
form.forEach { (k, v) ->
body.addEncoded(k, v)
}
val request = Request.Builder()
.post(body.build())
.url(url)
if (headers != null) {
request.headers(headers)
}
return httpClient.newCall(request.build()).await().ensureSuccess()
}
/**
* Execute JavaScript code and return result
* @param script JavaScript source code
* @param baseUrl url of page script will be executed in context of
* @return execution result as string, may be null
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param payload payload as `key=value` string with `&` separator
* @param headers an additional headers for request, may be null
*/
public abstract suspend fun evaluateJs(baseUrl: String, script: String): String?
suspend fun httpPost(
url: String,
payload: String,
headers: Headers?,
): Response {
val body = FormBody.Builder()
payload.split('&').forEach {
val pos = it.indexOf('=')
if (pos != -1) {
val k = it.substring(0, pos)
val v = it.substring(pos + 1)
body.addEncoded(k, v)
}
}
val request = Request.Builder()
.post(body.build())
.url(url)
if (headers != null) {
request.headers(headers)
}
return httpClient.newCall(request.build()).await().ensureSuccess()
}
/**
* Open [url] in browser for some external action (e.g. captcha solving or non cookie-based authorization)
* Do a GraphQL request to specific url
* @param endpoint an url
* @param query GraphQL request payload
*/
public open fun requestBrowserAction(parser: MangaParser, url: String): Nothing {
throw UnsupportedOperationException("Browser is not available")
suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
val body = JSONObject()
body.put("operationName", null as Any?)
body.put("variables", JSONObject())
body.put("query", "{$query}")
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = body.toString().toRequestBody(mediaType)
val request = Request.Builder()
.post(requestBody)
.url(endpoint)
val json = httpClient.newCall(request.build()).await().ensureSuccess().parseJson()
json.optJSONArray("errors")?.let {
if (it.length() != 0) {
throw GraphQLException(it)
}
}
return json
}
public abstract fun getConfig(source: MangaSource): MangaSourceConfig
open fun encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
public abstract fun getDefaultUserAgent(): String
open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data)
/**
* Helper function to be used in an interceptor
* to descramble images
* @param response Image response
* @param redraw lambda function to implement descrambling logic
*/
public abstract fun redrawImageResponse(
response: Response,
redraw: (image: Bitmap) -> Bitmap,
): Response
open fun getPreferredLocales(): List<Locale> = listOf(Locale.getDefault())
/**
* create a new empty Bitmap with given dimensions
* Execute JavaScript code and return result
* @param script JavaScript source code
* @return execution result as string, may be null
*/
public abstract fun createBitmap(
width: Int,
height: Int,
): Bitmap
abstract suspend fun evaluateJs(script: String): String?
abstract fun getConfig(source: MangaSource): MangaSourceConfig
private fun Response.ensureSuccess() = apply {
val exception: Exception? = when (code) { // Catch some error codes, not all
in 500..599 -> HttpStatusException(message, code, request.url.toString())
else -> null
}
if (exception != null) {
runCatching {
close()
}.onFailure {
exception.addSuppressed(it)
}
throw exception
}
}
}

@ -1,88 +1,163 @@
package org.koitharu.kotatsu.parsers
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Interceptor
import androidx.annotation.CallSuper
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
import org.koitharu.kotatsu.parsers.util.LinkResolver
import org.koitharu.kotatsu.parsers.util.convertToMangaSearchQuery
import org.koitharu.kotatsu.parsers.util.toMangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import java.util.*
public interface MangaParser : Interceptor {
abstract class MangaParser @InternalParsersApi constructor(val source: MangaSource) {
public val source: MangaParserSource
protected abstract val context: MangaLoaderContext
/**
* Supported [SortOrder] variants. Must not be empty.
*
* For better performance use [EnumSet] for more than one item.
*/
public val availableSortOrders: Set<SortOrder>
abstract val sortOrders: Set<SortOrder>
@Deprecated("Too complex. Use filterCapabilities instead")
public val searchQueryCapabilities: MangaSearchQueryCapabilities
public val filterCapabilities: MangaListFilterCapabilities
public val config: MangaSourceConfig
public val authorizationProvider: MangaParserAuthProvider?
get() = this as? MangaParserAuthProvider
val config by lazy { context.getConfig(source) }
/**
* Provide default domain and available alternatives, if any.
*
* Never hardcode domain in requests, use [domain] instead.
* Never hardcode domain in requests, use [getDomain] instead.
*/
public val configKeyDomain: ConfigKey.Domain
protected abstract val configKeyDomain: ConfigKey.Domain
public val domain: String
/**
* Used as fallback if value of `sortOrder` passed to [getList] is null
*/
protected open val defaultSortOrder: SortOrder
get() {
val supported = sortOrders
return SortOrder.values().first { it in supported }
}
@Deprecated("Too complex. Use getList with filter instead")
public suspend fun getList(query: MangaSearchQuery): List<Manga>
/**
* Parse list of manga by specified criteria
*
* @param offset starting from 0 and used for pagination.
* Note than passed value may not be divisible by internal page size, so you should adjust it manually.
* @param query search query, may be null or empty if no search needed
* @param tags genres for filtering, values from [getTags] and [Manga.tags]. May be null or empty
* @param sortOrder one of [sortOrders] or null for default value
*/
@JvmSynthetic
@InternalParsersApi
abstract suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga>
public suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga>
/**
* Parse list of manga with search by text query
*
* @param offset starting from 0 and used for pagination.
* @param query search query
*/
suspend fun getList(offset: Int, query: String): List<Manga> {
return getList(offset, query, null, defaultSortOrder)
}
/**
* Parse list of manga by specified criteria
*
* @param offset starting from 0 and used for pagination.
* Note than passed value may not be divisible by internal page size, so you should adjust it manually.
* @param tags genres for filtering, values from [getTags] and [Manga.tags]. May be null or empty
* @param sortOrder one of [sortOrders] or null for default value
*/
suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
return getList(offset, null, tags, sortOrder ?: defaultSortOrder)
}
/**
* Parse details for [Manga]: chapters list, description, large cover, etc.
* Must return the same manga, may change any fields excepts id, url and source
* @see Manga.copy
*/
public suspend fun getDetails(manga: Manga): Manga
abstract suspend fun getDetails(manga: Manga): Manga
/**
* Parse pages list for specified chapter.
* @see MangaPage for details
*/
public suspend fun getPages(chapter: MangaChapter): List<MangaPage>
abstract suspend fun getPages(chapter: MangaChapter): List<MangaPage>
/**
* Fetch direct link to the page image.
*/
public suspend fun getPageUrl(page: MangaPage): String
open suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(getDomain())
public suspend fun getFilterOptions(): MangaListFilterOptions
/**
* Fetch available tags (genres) for source
*/
abstract suspend fun getTags(): Set<MangaTag>
/**
* Parse favicons from the main page of the source`s website
* Returns direct link to the website favicon
*/
public suspend fun getFavicons(): Favicons
open fun getFaviconUrl() = "https://${getDomain()}/favicon.ico"
@CallSuper
open fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
keys.add(configKeyDomain)
}
public fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>)
/* Utils */
public suspend fun getRelatedManga(seed: Manga): List<Manga>
fun getDomain(): String {
return config[configKeyDomain]
}
public fun getRequestHeaders(): Headers
fun getDomain(subdomain: String): String {
val domain = getDomain()
return subdomain + "." + domain.removePrefix("www.")
}
/**
* Return [Manga] object by web link to it
* @see [Manga.publicUrl]
* Create a unique id for [Manga]/[MangaChapter]/[MangaPage].
* @param url must be relative url, without a domain
* @see [Manga.id]
* @see [MangaChapter.id]
* @see [MangaPage.id]
*/
@InternalParsersApi
public suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga?
protected fun generateUid(url: String): Long {
var h = 1125899906842597L
source.name.forEach { c ->
h = 31 * h + c.code
}
url.forEach { c ->
h = 31 * h + c.code
}
return h
}
/**
* Create a unique id for [Manga]/[MangaChapter]/[MangaPage].
* @param id an internal identifier
* @see [Manga.id]
* @see [MangaChapter.id]
* @see [MangaPage.id]
*/
@InternalParsersApi
protected fun generateUid(id: Long): Long {
var h = 1125899906842597L
source.name.forEach { c ->
h = 31 * h + c.code
}
h = 31 * h + id
return h
}
@InternalParsersApi
protected fun parseFailed(message: String? = null): Nothing {
throw ParseException(message, null)
}
}

@ -6,19 +6,19 @@ import org.koitharu.kotatsu.parsers.exception.ParseException
/**
* Implement this in your parser for authorization support
*/
public interface MangaParserAuthProvider {
interface MangaParserAuthProvider {
/**
* Return link to the login page, which will be opened in browser.
* Must be an absolute url
*/
public val authUrl: String
val authUrl: String
/**
* Quick check if user is logged in.
* In most case you should check for cookies in [MangaLoaderContext.cookieJar].
*/
public suspend fun isAuthorized(): Boolean
val isAuthorized: Boolean
/**
* Fetch and return current user`s name or login.
@ -26,5 +26,5 @@ public interface MangaParserAuthProvider {
* @throws [AuthRequiredException] if user is not logged in or authorization is expired
* @throws [ParseException] on parsing error
*/
public suspend fun getUsername(): String
suspend fun getUsername(): String
}

@ -1,28 +1,20 @@
package org.koitharu.kotatsu.parsers
import org.koitharu.kotatsu.parsers.model.ContentType
/**
* Annotate each [MangaParser] implementation with this annotation, used by codegen
*/
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
internal annotation class MangaSourceParser(
annotation class MangaSourceParser(
/**
* Name of manga source. Used as an Enum value, must be UPPER_CASE and unique.
*/
val name: String,
/**
* User-friendly title of manga source. In most case equals the website name.
* Avoid extra whitespaces between the words if it is not required.
*/
val title: String,
/**
* Language code (for example "en" or "ru") or blank if parser provide manga on different languages.
*/
val locale: String = "",
/**
* Type of content provided by parser. See [ContentType] for more info
*/
val type: ContentType = ContentType.MANGA,
)

@ -1,9 +0,0 @@
package org.koitharu.kotatsu.parsers.bitmap
public interface Bitmap {
public val width: Int
public val height: Int
public fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect)
}

@ -1,15 +0,0 @@
package org.koitharu.kotatsu.parsers.bitmap
public data class Rect(
val left: Int = 0,
val top: Int = 0,
val right: Int = 0,
val bottom: Int = 0,
) {
val width: Int
get() = right - left
val height: Int
get() = bottom - top
}

@ -1,37 +1,13 @@
package org.koitharu.kotatsu.parsers.config
public sealed class ConfigKey<T>(
@JvmField public val key: String,
sealed class ConfigKey<T>(
val key: String,
) {
public abstract val defaultValue: T
abstract val defaultValue: T
public class Domain(
@JvmField @JvmSuppressWildcards public vararg val presetValues: String,
) : ConfigKey<String>("domain") {
init {
require(presetValues.isNotEmpty()) { "You must provide at least one domain" }
}
override val defaultValue: String
get() = presetValues.first()
}
public class ShowSuspiciousContent(
override val defaultValue: Boolean,
) : ConfigKey<Boolean>("show_suspicious")
public class UserAgent(
class Domain(
override val defaultValue: String,
) : ConfigKey<String>("user_agent")
public class SplitByTranslations(
override val defaultValue: Boolean,
) : ConfigKey<Boolean>("split_translations")
public class PreferredImageServer(
public val presetValues: Map<String?, String?>,
override val defaultValue: String?,
) : ConfigKey<String?>("img_server")
val presetValues: Array<String>?,
) : ConfigKey<String>("domain")
}

@ -1,6 +1,6 @@
package org.koitharu.kotatsu.parsers.config
public interface MangaSourceConfig {
interface MangaSourceConfig {
public operator fun <T> get(key: ConfigKey<T>): T
operator fun <T> get(key: ConfigKey<T>): T
}

@ -1,105 +0,0 @@
package org.koitharu.kotatsu.parsers.core
import androidx.annotation.CallSuper
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
import org.koitharu.kotatsu.parsers.network.WebClient
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@Suppress("OVERRIDE_DEPRECATION")
@InternalParsersApi
public abstract class AbstractMangaParser @InternalParsersApi constructor(
@property:InternalParsersApi public val context: MangaLoaderContext,
public final override val source: MangaParserSource,
) : MangaParser {
public final override val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = filterCapabilities.toMangaSearchQueryCapabilities()
public override val config: MangaSourceConfig by lazy { context.getConfig(source) }
public open val sourceLocale: Locale
get() = if (source.locale.isEmpty()) Locale.ROOT else Locale(source.locale)
protected val sourceContentRating: ContentRating?
get() = if (source.contentType == ContentType.HENTAI) {
ContentRating.ADULT
} else {
null
}
protected val isNsfwSource: Boolean = source.contentType == ContentType.HENTAI
protected open val userAgentKey: ConfigKey.UserAgent = ConfigKey.UserAgent(context.getDefaultUserAgent())
override fun getRequestHeaders(): Headers = Headers.Builder()
.add("User-Agent", config[userAgentKey])
.build()
/**
* Used as fallback if value of `order` passed to [getList] is null
*/
public open val defaultSortOrder: SortOrder
get() {
val supported = availableSortOrders
return SortOrder.entries.first { it in supported }
}
final override val domain: String
get() = config[configKeyDomain]
@JvmField
protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source)
/**
* Search list of manga by specified searchQuery
*
* @param query searchQuery
*/
public final override suspend fun getList(query: MangaSearchQuery): List<Manga> = getList(
offset = query.offset,
order = query.order ?: defaultSortOrder,
filter = convertToMangaListFilter(query),
)
/**
* Fetch direct link to the page image.
*/
public override suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(domain)
/**
* Parse favicons from the main page of the source`s website
*/
public override suspend fun getFavicons(): Favicons {
return FaviconParser(webClient, domain).parseFavicons()
}
@CallSuper
public override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
keys.add(configKeyDomain)
}
public override suspend fun getRelatedManga(seed: Manga): List<Manga> {
return RelatedMangaFinder(listOf(this)).invoke(seed)
}
/**
* Return [Manga] object by web link to it
* @see [Manga.publicUrl]
*/
override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? = null
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request())
}

@ -1,94 +0,0 @@
package org.koitharu.kotatsu.parsers.core
import androidx.annotation.CallSuper
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
import org.koitharu.kotatsu.parsers.network.WebClient
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@Deprecated("Too complex. Use AbstractMangaParser instead")
internal abstract class FlexibleMangaParser @InternalParsersApi constructor(
@property:InternalParsersApi val context: MangaLoaderContext,
final override val source: MangaParserSource,
) : MangaParser {
override val config: MangaSourceConfig by lazy { context.getConfig(source) }
open val sourceLocale: Locale
get() = if (source.locale.isEmpty()) Locale.ROOT else Locale(source.locale)
protected open val userAgentKey: ConfigKey.UserAgent = ConfigKey.UserAgent(context.getDefaultUserAgent())
final override val filterCapabilities: MangaListFilterCapabilities
get() = searchQueryCapabilities.toMangaListFilterCapabilities()
protected val sourceContentRating: ContentRating?
get() = if (source.contentType == ContentType.HENTAI) {
ContentRating.ADULT
} else {
null
}
final override val domain: String
get() = config[configKeyDomain]
@Deprecated("Override intercept() instead")
override fun getRequestHeaders(): Headers = Headers.Builder()
.add("User-Agent", config[userAgentKey])
.build()
/**
* Used as fallback if value of `order` passed to [getList] is null
*/
open val defaultSortOrder: SortOrder
get() {
val supported = availableSortOrders
return SortOrder.entries.first { it in supported }
}
@JvmField
protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source)
/**
* Fetch direct link to the page image.
*/
override suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(domain)
final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
return getList(convertToMangaSearchQuery(offset, order, filter))
}
/**
* Parse favicons from the main page of the source`s website
*/
override suspend fun getFavicons(): Favicons {
return FaviconParser(webClient, domain).parseFavicons()
}
@CallSuper
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
keys.add(configKeyDomain)
}
override suspend fun getRelatedManga(seed: Manga): List<Manga> {
return RelatedMangaFinder(listOf(this)).invoke(seed)
}
/**
* Return [Manga] object by web link to it
* @see [Manga.publicUrl]
*/
override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? = null
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request())
}

@ -1,60 +0,0 @@
package org.koitharu.kotatsu.parsers.core
import androidx.annotation.VisibleForTesting
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.SearchableField
import org.koitharu.kotatsu.parsers.util.Paginator
@Deprecated("Too complex. Use PagedMangaParser instead")
internal abstract class FlexiblePagedMangaParser(
context: MangaLoaderContext,
source: MangaParserSource,
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) @JvmField public val pageSize: Int,
searchPageSize: Int = pageSize,
) : FlexibleMangaParser(context, source) {
@JvmField
protected val paginator: Paginator = Paginator(pageSize)
@JvmField
protected val searchPaginator: Paginator = Paginator(searchPageSize)
final override suspend fun getList(query: MangaSearchQuery): List<Manga> {
var containTitleNameCriteria = false
query.criteria.forEach {
if (it.field == SearchableField.TITLE_NAME) {
containTitleNameCriteria = true
}
}
return searchManga(
paginator = if (containTitleNameCriteria) {
paginator
} else {
searchPaginator
},
query = query,
)
}
public abstract suspend fun getListPage(query: MangaSearchQuery, page: Int): List<Manga>
protected fun setFirstPage(firstPage: Int, firstPageForSearch: Int = firstPage) {
paginator.firstPage = firstPage
searchPaginator.firstPage = firstPageForSearch
}
private suspend fun searchManga(
paginator: Paginator,
query: MangaSearchQuery,
): List<Manga> {
val offset: Int = query.offset
val page = paginator.getPage(offset)
val list = getListPage(query, page)
paginator.onListReceived(offset, page, list.size)
return list
}
}

@ -1,77 +0,0 @@
package org.koitharu.kotatsu.parsers.core
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.util.mergeWith
internal class MangaParserWrapper(
private val delegate: MangaParser,
) : MangaParser by delegate {
override val authorizationProvider: MangaParserAuthProvider?
get() = delegate as? MangaParserAuthProvider
@Deprecated("Too complex. Use getList with filter instead")
override suspend fun getList(query: MangaSearchQuery): List<Manga> = withContext(Dispatchers.Default) {
if (!query.skipValidation) {
searchQueryCapabilities.validate(query)
}
delegate.getList(query)
}
override suspend fun getList(
offset: Int,
order: SortOrder,
filter: MangaListFilter,
): List<Manga> = withContext(Dispatchers.Default) {
delegate.getList(offset, order, filter)
}
override suspend fun getDetails(manga: Manga): Manga = withContext(Dispatchers.Default) {
delegate.getDetails(manga)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = withContext(Dispatchers.Default) {
delegate.getPages(chapter)
}
override suspend fun getPageUrl(page: MangaPage): String = withContext(Dispatchers.Default) {
delegate.getPageUrl(page)
}
override suspend fun getFilterOptions(): MangaListFilterOptions = withContext(Dispatchers.Default) {
delegate.getFilterOptions()
}
override suspend fun getFavicons(): Favicons = withContext(Dispatchers.Default) {
delegate.getFavicons()
}
override suspend fun getRelatedManga(seed: Manga): List<Manga> = withContext(Dispatchers.Default) {
delegate.getRelatedManga(seed)
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val headers = request.headers.newBuilder()
.mergeWith(delegate.getRequestHeaders(), replaceExisting = false)
.build()
val newRequest = request.newBuilder().headers(headers).build()
return delegate.intercept(ProxyChain(chain, newRequest))
}
private class ProxyChain(
private val delegate: Interceptor.Chain,
private val request: Request,
) : Interceptor.Chain by delegate {
override fun request(): Request = request
}
}

@ -1,57 +0,0 @@
package org.koitharu.kotatsu.parsers.core
import androidx.annotation.VisibleForTesting
import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.Paginator
@InternalParsersApi
public abstract class PagedMangaParser(
context: MangaLoaderContext,
source: MangaParserSource,
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) @JvmField public val pageSize: Int,
searchPageSize: Int = pageSize,
) : AbstractMangaParser(context, source) {
@JvmField
protected val paginator: Paginator = Paginator(pageSize)
@JvmField
protected val searchPaginator: Paginator = Paginator(searchPageSize)
final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
return getList(
paginator = if (filter.query.isNullOrEmpty()) {
paginator
} else {
searchPaginator
},
offset = offset,
order = order,
filter = filter,
)
}
public abstract suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga>
protected fun setFirstPage(firstPage: Int, firstPageForSearch: Int = firstPage) {
paginator.firstPage = firstPage
searchPaginator.firstPage = firstPageForSearch
}
private suspend fun getList(
paginator: Paginator,
offset: Int,
order: SortOrder,
filter: MangaListFilter,
): List<Manga> {
val page = paginator.getPage(offset)
val list = getListPage(page, order, filter)
paginator.onListReceived(offset, page, list.size)
return list
}
}

@ -1,24 +0,0 @@
package org.koitharu.kotatsu.parsers.core
import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder
@InternalParsersApi
public abstract class SinglePageMangaParser(
context: MangaLoaderContext,
source: MangaParserSource,
) : AbstractMangaParser(context, source) {
final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
if (offset > 0) {
return emptyList()
}
return getList(order, filter)
}
public abstract suspend fun getList(order: SortOrder, filter: MangaListFilter): List<Manga>
}

@ -1,13 +1,11 @@
package org.koitharu.kotatsu.parsers.exception
import okio.IOException
import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.model.MangaSource
/**
* Authorization is required for access to the requested content
*/
public class AuthRequiredException @InternalParsersApi @JvmOverloads constructor(
public val source: MangaSource,
cause: Throwable? = null,
) : IOException("Authorization required", cause)
class AuthRequiredException @InternalParsersApi constructor(
val source: MangaSource,
) : RuntimeException("Authorization required")

@ -0,0 +1,7 @@
package org.koitharu.kotatsu.parsers.exception
import okio.IOException
class CloudFlareProtectedException(
val url: String,
) : IOException("Protected by CloudFlare: $url")

@ -1,3 +0,0 @@
package org.koitharu.kotatsu.parsers.exception
public class ContentUnavailableException(message: String) : RuntimeException(message)

@ -2,12 +2,11 @@ package org.koitharu.kotatsu.parsers.exception
import okio.IOException
import org.json.JSONArray
import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
public class GraphQLException @InternalParsersApi constructor(errors: JSONArray) : IOException() {
class GraphQLException(private val errors: JSONArray) : IOException() {
public val messages: List<String> = errors.mapJSONNotNull {
val messages = errors.mapJSON {
it.getString("message")
}

@ -1,9 +0,0 @@
package org.koitharu.kotatsu.parsers.exception
import org.jsoup.HttpStatusException
import java.net.HttpURLConnection
public class NotFoundException(
message: String,
url: String,
) : HttpStatusException(message, HttpURLConnection.HTTP_NOT_FOUND, url)

@ -2,8 +2,7 @@ package org.koitharu.kotatsu.parsers.exception
import org.koitharu.kotatsu.parsers.InternalParsersApi
public class ParseException @InternalParsersApi @JvmOverloads constructor(
public val shortMessage: String?,
public val url: String,
class ParseException @InternalParsersApi @JvmOverloads constructor(
message: String?,
cause: Throwable? = null,
) : RuntimeException("$shortMessage at $url", cause)
) : RuntimeException(message, cause)

@ -1,31 +0,0 @@
package org.koitharu.kotatsu.parsers.exception
import okio.IOException
import java.time.Instant
import java.time.temporal.ChronoUnit
public class TooManyRequestExceptions(
public val url: String,
retryAfter: Long,
) : IOException("Too man requests") {
public val retryAt: Instant? = if (retryAfter > 0 && retryAfter < Long.MAX_VALUE) {
Instant.now().plusMillis(retryAfter)
} else {
null
}
public fun getRetryDelay(): Long {
if (retryAt == null) {
return -1L
}
return Instant.now().until(retryAt, ChronoUnit.MILLIS).coerceAtLeast(0L)
}
override val message: String?
get() = if (retryAt != null) {
"${super.message}, retry at $retryAt"
} else {
super.message
}
}

@ -2,10 +2,4 @@
package org.koitharu.kotatsu.parsers.model
public const val RATING_UNKNOWN: Float = -1f
public const val YEAR_UNKNOWN: Int = 0
public const val YEAR_MIN: Int = 1900
public const val YEAR_MAX: Int = 2099
const val RATING_UNKNOWN = -1f

@ -1,7 +0,0 @@
package org.koitharu.kotatsu.parsers.model
public enum class ContentRating {
SAFE,
SUGGESTIVE,
ADULT
}

@ -1,36 +0,0 @@
package org.koitharu.kotatsu.parsers.model
public enum class ContentType {
/**
* Standard manga, manhua, webtoons, etc
*/
MANGA,
MANHWA,
MANHUA,
/**
* Use this if the source provides mostly nsfw content.
*/
HENTAI,
/**
* Western comics
*/
COMICS,
NOVEL,
/**
* Use this type if no other suits your needs. For example, for an indie manga
*/
ONE_SHOT,
DOUJINSHI,
IMAGE_SET,
ARTIST_CG,
GAME_CG,
OTHER,
}

@ -1,10 +0,0 @@
package org.koitharu.kotatsu.parsers.model
public enum class Demographic {
SHOUNEN,
SHOUJO,
SEINEN,
JOSEI,
KODOMO,
NONE,
}

@ -1,28 +0,0 @@
package org.koitharu.kotatsu.parsers.model
import okhttp3.HttpUrl.Companion.toHttpUrl
public data class Favicon(
@JvmField public val url: String,
@JvmField public val size: Int,
@JvmField internal val rel: String?,
) : Comparable<Favicon> {
@JvmField
public val type: String = url.toHttpUrl().pathSegments.last()
.substringAfterLast('.', "").lowercase()
override fun compareTo(other: Favicon): Int {
val res = size.compareTo(other.size)
if (res != 0) {
return res
}
return relWeightOf(rel).compareTo(relWeightOf(other.rel))
}
private fun relWeightOf(rel: String?) = when (rel) {
"apple-touch-icon" -> 1 // Prefer apple-touch-icon because it has a better quality
"mask-icon" -> -1
else -> 0
}
}

@ -1,59 +0,0 @@
package org.koitharu.kotatsu.parsers.model
public class Favicons(
favicons: Collection<Favicon>,
@JvmField public val referer: String?,
) : Collection<Favicon> {
private val icons = favicons.sortedDescending()
override val size: Int
get() = icons.size
override fun contains(element: Favicon): Boolean = icons.contains(element)
override fun containsAll(elements: Collection<Favicon>): Boolean = icons.containsAll(elements)
override fun isEmpty(): Boolean = icons.isEmpty()
override fun iterator(): Iterator<Favicon> = icons.iterator()
public operator fun minus(victim: Favicon): Favicons = Favicons(
favicons = icons.filterNot { it == victim },
referer = referer,
)
/**
* Finds a favicon whose size in pixels is greater than or equal to the specified size.
* If such icon is not available returns the largest icon
* @param size in pixels
* @param types supported file types, e.g. png, svg, ico. May be null but not empty
*/
@JvmOverloads
public fun find(size: Int, types: Set<String>? = null): Favicon? {
if (icons.isEmpty()) {
return null
}
var result: Favicon? = null
for (icon in icons) {
if (types != null && icon.type !in types) {
continue
}
if (result == null || icon.size >= size) {
result = icon
} else {
break
}
}
return result
}
public companion object {
@JvmStatic
public val EMPTY: Favicons = Favicons(emptySet(), null)
@JvmStatic
public fun single(url: String): Favicons = Favicons(setOf(Favicon(url, 0, null)), null)
}
}

@ -1,203 +1,158 @@
package org.koitharu.kotatsu.parsers.model
import androidx.collection.ArrayMap
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.InternalParsersApi
public data class Manga(
class Manga(
/**
* Unique identifier for manga
*/
@JvmField public val id: Long,
val id: Long,
/**
* Manga title, human-readable
*/
@JvmField public val title: String,
val title: String,
/**
* Alternative titles (for example on other language), may be empty
* Alternative title (for example on other language), may be null
*/
@JvmField public val altTitles: Set<String>,
val altTitle: String?,
/**
* Relative url to manga (**without** a domain) or any other uri.
* Used principally in parsers
*/
@JvmField public val url: String,
val url: String,
/**
* Absolute url to manga, must be ready to open in browser
*/
@JvmField public val publicUrl: String,
val publicUrl: String,
/**
* Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown
* @see hasRating
*/
@JvmField public val rating: Float,
val rating: Float,
/**
* Indicates that manga may contain sensitive information (18+, NSFW)
*/
@JvmField public val contentRating: ContentRating?,
val isNsfw: Boolean,
/**
* Absolute link to the cover
* @see largeCoverUrl
*/
@JvmField public val coverUrl: String?,
val coverUrl: String,
/**
* Tags (genres) of the manga
*/
@JvmField public val tags: Set<MangaTag>,
val tags: Set<MangaTag>,
/**
* Manga status (ongoing, finished) or null if unknown
*/
@JvmField public val state: MangaState?,
val state: MangaState?,
/**
* Authors of the manga
* Author of the manga, may be null
*/
@JvmField public val authors: Set<String>,
val author: String?,
/**
* Large cover url (absolute), null if is no large cover
* @see coverUrl
*/
@JvmField public val largeCoverUrl: String? = null,
val largeCoverUrl: String? = null,
/**
* Manga description, may be html or null
*/
@JvmField public val description: String? = null,
val description: String? = null,
/**
* List of chapters
*/
@JvmField public val chapters: List<MangaChapter>? = null,
val chapters: List<MangaChapter>? = null,
/**
* Manga source
*/
@JvmField public val source: MangaSource,
val source: MangaSource,
) {
@Deprecated("Use other constructor")
public constructor(
/**
* Unique identifier for manga
*/
id: Long,
/**
* Manga title, human-readable
*/
title: String,
/**
* Alternative title (for example on other language), may be null
*/
altTitle: String?,
/**
* Relative url to manga (**without** a domain) or any other uri.
* Used principally in parsers
*/
url: String,
/**
* Absolute url to manga, must be ready to open in browser
*/
publicUrl: String,
/**
* Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown
* @see hasRating
*/
rating: Float,
/**
* Indicates that manga may contain sensitive information (18+, NSFW)
*/
isNsfw: Boolean,
/**
* Absolute link to the cover
* @see largeCoverUrl
*/
coverUrl: String?,
/**
* Tags (genres) of the manga
*/
tags: Set<MangaTag>,
/**
* Manga status (ongoing, finished) or null if unknown
*/
state: MangaState?,
/**
* Authors of the manga
*/
author: String?,
/**
* Large cover url (absolute), null if is no large cover
* @see coverUrl
*/
largeCoverUrl: String? = null,
/**
* Manga description, may be html or null
*/
description: String? = null,
/**
* List of chapters
*/
chapters: List<MangaChapter>? = null,
/**
* Manga source
* Return if manga has a specified rating
* @see rating
*/
source: MangaSource,
) : this(
val hasRating: Boolean
get() = rating in 0f..1f
fun getChapters(branch: String?): List<MangaChapter>? {
return chapters?.filter { x -> x.branch == branch }
}
@InternalParsersApi
fun copy(
title: String = this.title,
altTitle: String? = this.altTitle,
publicUrl: String = this.publicUrl,
rating: Float = this.rating,
isNsfw: Boolean = this.isNsfw,
coverUrl: String = this.coverUrl,
tags: Set<MangaTag> = this.tags,
state: MangaState? = this.state,
author: String? = this.author,
largeCoverUrl: String? = this.largeCoverUrl,
description: String? = this.description,
chapters: List<MangaChapter>? = this.chapters,
) = Manga(
id = id,
title = title,
altTitles = setOfNotNull(altTitle?.nullIfEmpty()),
altTitle = altTitle,
url = url,
publicUrl = publicUrl,
rating = rating,
contentRating = if (isNsfw) ContentRating.ADULT else null,
coverUrl = coverUrl?.nullIfEmpty(),
isNsfw = isNsfw,
coverUrl = coverUrl,
tags = tags,
state = state,
authors = setOfNotNull(author),
largeCoverUrl = largeCoverUrl?.nullIfEmpty(),
description = description?.nullIfEmpty(),
author = author,
largeCoverUrl = largeCoverUrl,
description = description,
chapters = chapters,
source = source,
)
/**
* Author of the manga, may be null
*/
@Deprecated("Please use authors")
public val author: String?
get() = authors.firstOrNull()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
/**
* Alternative title (for example on other language), may be null
*/
@Deprecated("Please use altTitles")
public val altTitle: String?
get() = altTitles.firstOrNull()
other as Manga
/**
* Return if manga has a specified rating
* @see rating
*/
public val hasRating: Boolean
get() = rating > 0f && rating <= 1f
@Deprecated("Use contentRating instead", ReplaceWith("contentRating == ContentRating.ADULT"))
public val isNsfw: Boolean
get() = contentRating == ContentRating.ADULT
if (id != other.id) return false
if (title != other.title) return false
if (altTitle != other.altTitle) return false
if (url != other.url) return false
if (publicUrl != other.publicUrl) return false
if (rating != other.rating) return false
if (isNsfw != other.isNsfw) return false
if (coverUrl != other.coverUrl) return false
if (tags != other.tags) return false
if (state != other.state) return false
if (author != other.author) return false
if (largeCoverUrl != other.largeCoverUrl) return false
if (description != other.description) return false
if (chapters != other.chapters) return false
if (source != other.source) return false
public fun getChapters(branch: String?): List<MangaChapter> {
return chapters?.filter { x -> x.branch == branch }.orEmpty()
return true
}
public fun findChapterById(id: Long): MangaChapter? = chapters?.findById(id)
public fun requireChapterById(id: Long): MangaChapter = findChapterById(id)
?: throw NoSuchElementException("Chapter with id $id not found")
public fun getBranches(): Map<String?, Int> {
if (chapters.isNullOrEmpty()) {
return emptyMap()
}
val result = ArrayMap<String?, Int>()
chapters.forEach {
val key = it.branch
result[key] = result.getOrDefault(key, 0) + 1
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + (altTitle?.hashCode() ?: 0)
result = 31 * result + url.hashCode()
result = 31 * result + publicUrl.hashCode()
result = 31 * result + rating.hashCode()
result = 31 * result + isNsfw.hashCode()
result = 31 * result + coverUrl.hashCode()
result = 31 * result + tags.hashCode()
result = 31 * result + (state?.hashCode() ?: 0)
result = 31 * result + (author?.hashCode() ?: 0)
result = 31 * result + (largeCoverUrl?.hashCode() ?: 0)
result = 31 * result + (description?.hashCode() ?: 0)
result = 31 * result + (chapters?.hashCode() ?: 0)
result = 31 * result + source.hashCode()
return result
}
}

@ -1,65 +1,70 @@
package org.koitharu.kotatsu.parsers.model
import org.koitharu.kotatsu.parsers.util.formatSimple
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
public data class MangaChapter(
class MangaChapter(
/**
* An unique id of chapter
*/
@JvmField public val id: Long,
/**
* User-readable name of chapter if provided by parser or null instead
* Do not pass manga title or chapter number here
*/
@JvmField public val title: String?,
val id: Long,
/**
* Chapter number starting from 1, 0 if unknown
* User-readable name of chapter
*/
@JvmField public val number: Float,
val name: String,
/**
* Volume number starting from 1, 0 if unknown
* Chapter number starting from 1
*/
@JvmField public val volume: Int,
val number: Int,
/**
* Relative url to chapter (**without** a domain) or any other uri.
* Used principally in parsers
*/
@JvmField public val url: String,
val url: String,
/**
* User-readable name of scanlator (releaser) or null if unknown
*/
@JvmField public val scanlator: String?,
val scanlator: String?,
/**
* Chapter upload date in milliseconds
*/
@JvmField public val uploadDate: Long,
val uploadDate: Long,
/**
* User-readable name of branch.
* A branch is a group of chapters that overlap (e.g. different languages)
*/
@JvmField public val branch: String?,
@JvmField public val source: MangaSource,
) {
val branch: String?,
val source: MangaSource,
) : Comparable<MangaChapter> {
@Deprecated("Use title instead", ReplaceWith("title"))
val name: String
get() = title.ifNullOrEmpty {
buildString {
if (volume > 0) append("Vol ").append(volume).append(' ')
if (number > 0) append("Chapter ").append(number.formatSimple()) else append("Unnamed")
}
override fun compareTo(other: MangaChapter): Int {
return number.compareTo(other.number)
}
public fun numberString(): String? = if (number > 0f) {
number.formatSimple()
} else {
null
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaChapter
if (id != other.id) return false
if (name != other.name) return false
if (number != other.number) return false
if (url != other.url) return false
if (scanlator != other.scanlator) return false
if (uploadDate != other.uploadDate) return false
if (branch != other.branch) return false
if (source != other.source) return false
return true
}
public fun volumeString(): String? = if (volume > 0) {
volume.toString()
} else {
null
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + number
result = 31 * result + url.hashCode()
result = 31 * result + (scanlator?.hashCode() ?: 0)
result = 31 * result + uploadDate.hashCode()
result = 31 * result + (branch?.hashCode() ?: 0)
result = 31 * result + source.hashCode()
return result
}
}

@ -1,88 +0,0 @@
package org.koitharu.kotatsu.parsers.model
import java.util.*
public data class MangaListFilter(
@JvmField val query: String? = null,
@JvmField val tags: Set<MangaTag> = emptySet(),
@JvmField val tagsExclude: Set<MangaTag> = emptySet(),
@JvmField val locale: Locale? = null,
@JvmField val originalLocale: Locale? = null,
@JvmField val states: Set<MangaState> = emptySet(),
@JvmField val contentRating: Set<ContentRating> = emptySet(),
@JvmField val types: Set<ContentType> = emptySet(),
@JvmField val demographics: Set<Demographic> = emptySet(),
@JvmField val year: Int = YEAR_UNKNOWN,
@JvmField val yearFrom: Int = YEAR_UNKNOWN,
@JvmField val yearTo: Int = YEAR_UNKNOWN,
@JvmField val author: String? = null,
) {
private fun isNonSearchOptionsEmpty(): Boolean = tags.isEmpty() &&
tagsExclude.isEmpty() &&
locale == null &&
originalLocale == null &&
states.isEmpty() &&
contentRating.isEmpty() &&
year == YEAR_UNKNOWN &&
yearFrom == YEAR_UNKNOWN &&
yearTo == YEAR_UNKNOWN &&
types.isEmpty() &&
demographics.isEmpty() &&
author.isNullOrEmpty()
public fun isEmpty(): Boolean = isNonSearchOptionsEmpty() && query.isNullOrEmpty()
public fun isNotEmpty(): Boolean = !isEmpty()
public fun hasNonSearchOptions(): Boolean = !isNonSearchOptionsEmpty()
public companion object {
@JvmStatic
public val EMPTY: MangaListFilter = MangaListFilter()
}
internal class Builder {
private var query: String? = null
private val tags: MutableSet<MangaTag> = mutableSetOf()
private val tagsExclude: MutableSet<MangaTag> = mutableSetOf()
private var locale: Locale? = null
private var originalLocale: Locale? = null
private val states: MutableSet<MangaState> = mutableSetOf()
private val contentRating: MutableSet<ContentRating> = mutableSetOf()
private val types: MutableSet<ContentType> = mutableSetOf()
private val demographics: MutableSet<Demographic> = mutableSetOf()
private var year: Int = YEAR_UNKNOWN
private var yearFrom: Int = YEAR_UNKNOWN
private var yearTo: Int = YEAR_UNKNOWN
fun query(query: String?): Builder = apply { this.query = query }
fun addTag(tag: MangaTag): Builder = apply { tags.add(tag) }
fun addTags(tags: Collection<MangaTag>): Builder = apply { this.tags.addAll(tags) }
fun excludeTag(tag: MangaTag): Builder = apply { tagsExclude.add(tag) }
fun excludeTags(tags: Collection<MangaTag>): Builder = apply { this.tagsExclude.addAll(tags) }
fun locale(locale: Locale?): Builder = apply { this.locale = locale }
fun originalLocale(locale: Locale?): Builder = apply { this.originalLocale = locale }
fun addState(state: MangaState): Builder = apply { states.add(state) }
fun addStates(states: Collection<MangaState>): Builder = apply { this.states.addAll(states) }
fun addContentRating(rating: ContentRating): Builder = apply { contentRating.add(rating) }
fun addContentRatings(ratings: Collection<ContentRating>): Builder =
apply { this.contentRating.addAll(ratings) }
fun addType(type: ContentType): Builder = apply { types.add(type) }
fun addTypes(types: Collection<ContentType>): Builder = apply { this.types.addAll(types) }
fun addDemographic(demographic: Demographic): Builder = apply { demographics.add(demographic) }
fun addDemographics(demographics: Collection<Demographic>): Builder =
apply { this.demographics.addAll(demographics) }
fun year(year: Int): Builder = apply { this.year = year }
fun yearFrom(year: Int): Builder = apply { this.yearFrom = year }
fun yearTo(year: Int): Builder = apply { this.yearTo = year }
fun build(): MangaListFilter = MangaListFilter(
query, tags, tagsExclude, locale, originalLocale, states,
contentRating, types, demographics, year, yearFrom, yearTo,
)
}
}

@ -1,56 +0,0 @@
package org.koitharu.kotatsu.parsers.model
import org.koitharu.kotatsu.parsers.InternalParsersApi
public data class MangaListFilterCapabilities @InternalParsersApi constructor(
/**
* Whether parser supports filtering by more than one tag
* @see [MangaListFilter.tags]
* @see [MangaListFilterOptions.availableTags]
*/
val isMultipleTagsSupported: Boolean = false,
/**
* Whether parser supports tagsExclude field in filter
* @see [MangaListFilter.tagsExclude]
* @see [MangaListFilterOptions.availableTags]
*/
val isTagsExclusionSupported: Boolean = false,
/**
* Whether parser supports searching by string query
* @see [MangaListFilter.query]
*/
val isSearchSupported: Boolean = false,
/**
* Whether parser supports searching by string query combined within other filters
*/
val isSearchWithFiltersSupported: Boolean = false,
/**
* Whether parser supports searching/filtering by year
* @see [MangaListFilter.year]
*/
val isYearSupported: Boolean = false,
/**
* Whether parser supports searching by year range
* @see [MangaListFilter.yearFrom] and [MangaListFilter.yearTo]
*/
val isYearRangeSupported: Boolean = false,
/**
* Whether parser supports searching Original Languages
* @see [MangaListFilter.originalLocale]
* @see [MangaListFilterOptions.availableLocales]
*/
val isOriginalLocaleSupported: Boolean = false,
/**
* Whether parser supports searching by author name
* @see [MangaListFilter.author]
*/
val isAuthorSearchSupported: Boolean = false,
)

@ -1,45 +0,0 @@
package org.koitharu.kotatsu.parsers.model
import org.koitharu.kotatsu.parsers.InternalParsersApi
import java.util.*
public data class MangaListFilterOptions @InternalParsersApi constructor(
/**
* Available tags (genres)
*/
public val availableTags: Set<MangaTag> = emptySet(),
/**
* Supported [MangaState] variants for filtering. May be empty.
*
* For better performance use [EnumSet] for more than one item.
*/
public val availableStates: Set<MangaState> = emptySet(),
/**
* Supported [ContentRating] variants for filtering. May be empty.
*
* For better performance use [EnumSet] for more than one item.
*/
public val availableContentRating: Set<ContentRating> = emptySet(),
/**
* Supported [ContentType] variants for filtering. May be empty.
*
* For better performance use [EnumSet] for more than one item.
*/
public val availableContentTypes: Set<ContentType> = emptySet(),
/**
* Supported [Demographic] variants for filtering. May be empty.
*
* For better performance use [EnumSet] for more than one item.
*/
public val availableDemographics: Set<Demographic> = emptySet(),
/**
* Supported content locales for multilingual sources
*/
public val availableLocales: Set<Locale> = emptySet(),
)

@ -2,21 +2,51 @@ package org.koitharu.kotatsu.parsers.model
import org.koitharu.kotatsu.parsers.MangaParser
public data class MangaPage(
class MangaPage(
/**
* Unique identifier for page
* Unique identifier for manga
*/
@JvmField public val id: Long,
val id: Long,
/**
* Relative url to page (**without** a domain) or any other uri.
* Used principally in parsers.
* May contain link to image or html page.
* @see MangaParser.getPageUrl
*/
@JvmField public val url: String,
val url: String,
/**
* Absolute link to the chapter or website home page.
* Used in Referer header
*/
val referer: String,
/**
* Absolute url of the small page image if exists, null otherwise
*/
@JvmField public val preview: String?,
@JvmField public val source: MangaSource,
)
val preview: String?,
val source: MangaSource,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaPage
if (id != other.id) return false
if (url != other.url) return false
if (referer != other.referer) return false
if (preview != other.preview) return false
if (source != other.source) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + url.hashCode()
result = 31 * result + referer.hashCode()
result = 31 * result + (preview?.hashCode() ?: 0)
result = 31 * result + source.hashCode()
return result
}
}

@ -1,6 +0,0 @@
package org.koitharu.kotatsu.parsers.model
public interface MangaSource {
public val name: String
}

@ -1,5 +1,5 @@
package org.koitharu.kotatsu.parsers.model
public enum class MangaState {
ONGOING, FINISHED, ABANDONED, PAUSED, UPCOMING, RESTRICTED
enum class MangaState {
ONGOING, FINISHED
}

@ -2,15 +2,36 @@ package org.koitharu.kotatsu.parsers.model
import org.koitharu.kotatsu.parsers.MangaParser
public data class MangaTag(
class MangaTag(
/**
* User-readable tag title, should be in Title case
*/
@JvmField public val title: String,
val title: String,
/**
* Identifier of a tag, must be unique among the source.
* @see MangaParser.getList
*/
@JvmField public val key: String,
@JvmField public val source: MangaSource,
)
val key: String,
val source: MangaSource,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaTag
if (title != other.title) return false
if (key != other.key) return false
if (source != other.source) return false
return true
}
override fun hashCode(): Int {
var result = title.hashCode()
result = 31 * result + key.hashCode()
result = 31 * result + source.hashCode()
return result
}
}

@ -1,22 +1,9 @@
package org.koitharu.kotatsu.parsers.model
public enum class SortOrder {
enum class SortOrder {
UPDATED,
UPDATED_ASC,
POPULARITY,
POPULARITY_ASC,
RATING,
RATING_ASC,
NEWEST,
NEWEST_ASC,
ALPHABETICAL,
ALPHABETICAL_DESC,
ADDED,
ADDED_ASC,
RELEVANCE,
POPULARITY_HOUR,
POPULARITY_TODAY,
POPULARITY_WEEK,
POPULARITY_MONTH,
POPULARITY_YEAR,
ALPHABETICAL
}

@ -3,9 +3,9 @@ package org.koitharu.kotatsu.parsers.model
import org.koitharu.kotatsu.parsers.InternalParsersApi
@InternalParsersApi
public class WordSet(private vararg val words: String) {
class WordSet(private vararg val words: String) {
public fun anyWordIn(dateString: String): Boolean = words.any { dateString.contains(it, ignoreCase = true) }
public fun startsWith(dateString: String): Boolean = words.any { dateString.startsWith(it, ignoreCase = true) }
public fun endsWith(dateString: String): Boolean = words.any { dateString.endsWith(it, ignoreCase = true) }
fun anyWordIn(dateString: String): Boolean = words.any {
dateString.contains(it, ignoreCase = true)
}
}

@ -1,90 +0,0 @@
package org.koitharu.kotatsu.parsers.model.search
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import org.koitharu.kotatsu.parsers.model.SortOrder
/**
* Represents a search query for filtering and sorting manga search results.
* This class is immutable and must be constructed using the [Builder].
*
* @property criteria The set of search criteria applied to the query.
* @property order The sorting order for the results (optional).
* @property offset The offset number for paginated search results (optional).
*/
@Deprecated("Too complex. Use MangaListFilter instead")
@ConsistentCopyVisibility
public data class MangaSearchQuery private constructor(
@JvmField public val criteria: Set<QueryCriteria<*>>,
@JvmField public val order: SortOrder?,
@JvmField public val offset: Int,
@JvmField public val skipValidation: Boolean,
) {
public fun newBuilder(): Builder = Builder(this)
public class Builder {
private val criteria = ArraySet<QueryCriteria<*>>()
private var order: SortOrder? = null
private var offset: Int = 0
private var skipValidation: Boolean = false
public constructor()
public constructor(query: MangaSearchQuery) : this() {
criteria.addAll(query.criteria)
order = query.order
offset = query.offset
}
public fun criterion(criterion: QueryCriteria<*>): Builder = apply { criteria.add(criterion) }
public fun order(order: SortOrder?): Builder = apply { this.order = order }
public fun offset(offset: Int): Builder = apply { this.offset = offset }
public fun skipValidation(skip: Boolean): Builder = apply { this.skipValidation = skip }
@Throws(IllegalArgumentException::class)
public fun build(): MangaSearchQuery {
return MangaSearchQuery(deduplicateCriteria(criteria), order, offset, skipValidation)
}
private fun deduplicateCriteria(criteria: Set<QueryCriteria<*>>): Set<QueryCriteria<*>> {
val uniqueCriteria =
ArrayMap<Pair<SearchableField, Class<out QueryCriteria<*>>>, QueryCriteria<*>>(criteria.size)
for (criterion in criteria) {
val key = criterion.field to criterion::class.java
val existing = uniqueCriteria[key]
when {
existing == null -> uniqueCriteria[key] = criterion
existing is QueryCriteria.Include<*> && criterion is QueryCriteria.Include<*> -> {
uniqueCriteria[key] =
QueryCriteria.Include(criterion.field, existing.values union criterion.values)
}
existing is QueryCriteria.Exclude<*> && criterion is QueryCriteria.Exclude<*> -> {
uniqueCriteria[key] =
QueryCriteria.Exclude(criterion.field, existing.values union criterion.values)
}
else -> throw IllegalArgumentException(
"Match and Range have only one criterion per type, but found duplicates for: ${criterion.field} in ${criterion::class.simpleName}",
)
}
}
return uniqueCriteria.values.toSet()
}
}
public companion object {
public val EMPTY: MangaSearchQuery = MangaSearchQuery(emptySet(), null, 0, false)
}
}

@ -1,48 +0,0 @@
package org.koitharu.kotatsu.parsers.model.search
import androidx.collection.ArraySet
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
import org.koitharu.kotatsu.parsers.util.mapToSet
@Deprecated("Too complex. Use MangaListFilterCapabilities instead")
@ExposedCopyVisibility
public data class MangaSearchQueryCapabilities internal constructor(
public val capabilities: Set<SearchCapability>,
) {
public constructor(vararg capabilities: SearchCapability) : this(ArraySet(capabilities))
internal fun validate(query: MangaSearchQuery) {
val strictFields = capabilities.filter { it.isExclusive }.mapToSet { it.field }
val usedStrictFields = query.criteria.mapToSet { it.field }.intersect(strictFields)
require(usedStrictFields.isEmpty() || query.criteria.size <= 1) {
"Query contains multiple criteria, but at least one field (${usedStrictFields.joinToString()}) does not support multiple criteria."
}
for (criterion in query.criteria) {
val capability = requireNotNull(capabilities.find { it.field == criterion.field }) {
"Unsupported search field: ${criterion.field}"
}
require(criterion::class in capability.criteriaTypes) {
"Unsupported search criterion: ${criterion::class.simpleName} for field ${criterion.field}"
}
// Ensure single value per criterion if supportMultiValue is false
if (!capability.isMultiple) {
when (criterion) {
is Include<*> -> require(criterion.values.size <= 1) {
"Multiple values are not allowed for field ${criterion.field}"
}
is Exclude<*> -> require(criterion.values.size <= 1) {
"Multiple values are not allowed for field ${criterion.field}"
}
is Range<*> -> Unit // Range is always valid (from, to)
is Match<*> -> Unit // Match always has a single value
}
}
}
}
}

@ -1,106 +0,0 @@
package org.koitharu.kotatsu.parsers.model.search
/**
* Represents a generic search criterion used for filtering manga search results.
* Each criterion applies a specific condition to a [SearchableField] and operates on values of type [T].
*
* @param T The type of value associated with the search criterion.
* @property field The field to which this search criterion applies.
*/
@Deprecated("Too complex")
public sealed interface QueryCriteria<T> {
public val field: SearchableField
override fun equals(other: Any?): Boolean
override fun hashCode(): Int
/**
* Represents an inclusion criterion that allows search results based on a set of allowed values.
*
* @param T The type of value being included in the search.
* @property values The set of values that should be included in the search results.
*
* ### Example Usage:
* ```kotlin
* val genreFilter = QueryCriteria.Include(SearchableField.STATE, setOf(MangaState.ONGOING, MangaState.FINISHED))
* ```
*/
public data class Include<T : Any>(
public override val field: SearchableField,
@JvmField public val values: Set<T>,
) : QueryCriteria<T> {
init {
check(values.all { x -> field.type.isInstance(x) })
}
}
/**
* Represents an exclusion criterion that exclude results containing certain values.
*
* @param T The type of value being excluded from the search.
* @property values The set of values that should be excluded from the search results.
*
* ### Example Usage:
* ```kotlin
* val excludeTag = QueryCriteria.Exclude(SearchableField.TAG, setOf(MangaTag(key, title, source)))
* ```
*/
public data class Exclude<T : Any>(
public override val field: SearchableField,
@JvmField public val values: Set<T>,
) : QueryCriteria<T> {
init {
check(values.all { x -> field.type.isInstance(x) })
}
}
/**
* Represents a range criterion that allows search based on a range of values.
*
* @param T The type of value used in the range (must be comparable).
* @property from The starting value of the range (inclusive).
* @property to The ending value of the range (inclusive).
*
* ### Example Usage:
* ```kotlin
* val yearRange = QueryCriteria.Range(SearchableField.PUBLICATION_YEAR, 2000, 2020)
* ```
*/
public data class Range<T : Comparable<T>>(
public override val field: SearchableField,
@JvmField public val from: T,
@JvmField public val to: T,
) : QueryCriteria<T> {
init {
check(field.type.isInstance(from))
check(field.type.isInstance(to))
}
}
/**
* Represents a match criterion that search results based on an exact match of a value.
*
* @param T The type of value being matched.
* @property value The exact value that must be matched.
*
* ### Example Usage:
* ```kotlin
* val titleMatch = QueryCriteria.Match(SearchableField.TITLE, "manga title")
* ```
*/
public data class Match<T : Any>(
public override val field: SearchableField,
@JvmField public val value: T,
) : QueryCriteria<T> {
init {
check(field.type.isInstance(value))
}
}
}

@ -1,34 +0,0 @@
package org.koitharu.kotatsu.parsers.model.search
import kotlin.reflect.KClass
/**
* Defines the search capabilities of a given field in the manga search query.
*
* @property field The searchable field that this capability applies to.
* Example values:
* - `SearchableField.TITLE_NAME` for searching by title.
* - `SearchableField.AUTHOR` for searching by author names.
* - `SearchableField.TAG` for filtering by tags.
* @property criteriaTypes The set of supported criteria types for the field.
* Example values:
* - `setOf(Include::class, Exclude::class)` selected field supports inclusion/exclusion criteria.
* - `setOf(Range::class)` selected field support numerical range criteria.
* @property isMultiValue Indicates whether the field supports multiple values.
* - `true` if multiple values can be provided (e.g., multiple tags or authors).
* - `false` if only a single value is allowed (e.g., only one tag or author).
* @property isExclusive Specifies whether the field can be used alongside other criteria.
* - `true` if this field can be used with other search criteria.
* - `false` if using this field requires it to be the only criterion in query.
*/
@Deprecated("Too complex")
public data class SearchCapability(
/** The searchable field that this capability applies to. */
@JvmField public val field: SearchableField,
/** The set of supported criteria types for this field. */
@JvmField public val criteriaTypes: Set<KClass<out QueryCriteria<*>>>,
/** Indicates whether the field supports multiple values. */
@JvmField public val isMultiple: Boolean,
/** Specifies whether the field can be used alongside other criteria. */
@JvmField public val isExclusive: Boolean = false,
)

@ -1,24 +0,0 @@
package org.koitharu.kotatsu.parsers.model.search
import org.koitharu.kotatsu.parsers.model.*
import java.util.*
/**
* Represents the various fields that can be used for searching manga.
* Each field is associated with a specific data type that defines its expected values.
*
* @property type The Java class representing the expected type of values for this field.
*/
@Deprecated("Too complex")
public enum class SearchableField(public val type: Class<*>) {
TITLE_NAME(String::class.java),
TAG(MangaTag::class.java),
AUTHOR(MangaTag::class.java),
LANGUAGE(Locale::class.java),
ORIGINAL_LANGUAGE(Locale::class.java),
STATE(MangaState::class.java),
CONTENT_TYPE(ContentType::class.java),
CONTENT_RATING(ContentRating::class.java),
DEMOGRAPHIC(Demographic::class.java),
PUBLICATION_YEAR(Int::class.javaObjectType);
}

@ -1,47 +0,0 @@
package org.koitharu.kotatsu.parsers.network
import okhttp3.CookieJar
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response
import org.jsoup.Jsoup
import java.net.HttpURLConnection.HTTP_FORBIDDEN
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
public object CloudFlareHelper {
public const val PROTECTION_NOT_DETECTED: Int = 0
public const val PROTECTION_CAPTCHA: Int = 1
public const val PROTECTION_BLOCKED: Int = 2
private const val CF_CLEARANCE = "cf_clearance"
public fun checkResponseForProtection(response: Response): Int {
if (response.code != HTTP_FORBIDDEN && response.code != HTTP_UNAVAILABLE) {
return PROTECTION_NOT_DETECTED
}
val content = try {
response.peekBody(Long.MAX_VALUE).use {
Jsoup.parse(it.byteStream(), Charsets.UTF_8.name(), response.request.url.toString())
}
} catch (_: IllegalStateException) {
return PROTECTION_NOT_DETECTED
}
return when {
content.selectFirst("h2[data-translate=\"blocked_why_headline\"]") != null -> PROTECTION_BLOCKED
content.getElementById("challenge-error-title") != null || content.getElementById("challenge-error-text") != null -> PROTECTION_CAPTCHA
else -> PROTECTION_NOT_DETECTED
}
}
public fun getClearanceCookie(cookieJar: CookieJar, url: String): String? {
return cookieJar.loadForRequest(url.toHttpUrl()).find { it.name == CF_CLEARANCE }?.value
}
public fun isCloudFlareCookie(name: String): Boolean {
return name.startsWith("cf_")
|| name.startsWith("_cf")
|| name.startsWith("__cf")
|| name == "csrftoken"
}
}

@ -1,133 +0,0 @@
package org.koitharu.kotatsu.parsers.network
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.GraphQLException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseJson
import java.net.HttpURLConnection
public class OkHttpWebClient(
private val httpClient: OkHttpClient,
private val mangaSource: MangaSource,
) : WebClient {
override suspend fun httpGet(url: HttpUrl, extraHeaders: Headers?): Response {
val request = Request.Builder()
.get()
.url(url)
.addTags()
.addExtraHeaders(extraHeaders)
return httpClient.newCall(request.build()).await().ensureSuccess()
}
override suspend fun httpHead(url: HttpUrl): Response {
val request = Request.Builder()
.head()
.url(url)
.addTags()
return httpClient.newCall(request.build()).await().ensureSuccess()
}
override suspend fun httpPost(url: HttpUrl, form: Map<String, String>, extraHeaders: Headers?): Response {
val body = FormBody.Builder()
form.forEach { (k, v) ->
body.addEncoded(k, v)
}
val request = Request.Builder()
.post(body.build())
.url(url)
.addTags()
.addExtraHeaders(extraHeaders)
return httpClient.newCall(request.build()).await().ensureSuccess()
}
override suspend fun httpPost(url: HttpUrl, payload: String, extraHeaders: Headers?): Response {
val body = FormBody.Builder()
payload.split('&').forEach {
val pos = it.indexOf('=')
if (pos != -1) {
val k = it.substring(0, pos)
val v = it.substring(pos + 1)
body.addEncoded(k, v)
}
}
val request = Request.Builder()
.post(body.build())
.url(url)
.addTags()
.addExtraHeaders(extraHeaders)
return httpClient.newCall(request.build()).await().ensureSuccess()
}
override suspend fun httpPost(url: HttpUrl, body: JSONObject, extraHeaders: Headers?): Response {
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = body.toString().toRequestBody(mediaType)
val request = Request.Builder()
.post(requestBody)
.url(url)
.addTags()
.addExtraHeaders(extraHeaders)
return httpClient.newCall(request.build()).await().ensureSuccess()
}
override suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
val body = JSONObject()
body.put("operationName", null as Any?)
body.put("variables", JSONObject())
body.put("query", "{$query}")
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = body.toString().toRequestBody(mediaType)
val request = Request.Builder()
.post(requestBody)
.url(endpoint)
.addTags()
val json = httpClient.newCall(request.build()).await().parseJson()
json.optJSONArray("errors")?.let {
if (it.length() != 0) {
throw GraphQLException(it)
}
}
return json
}
private fun Request.Builder.addTags(): Request.Builder {
tag(MangaSource::class.java, mangaSource)
return this
}
private fun Request.Builder.addExtraHeaders(headers: Headers?): Request.Builder {
if (headers != null) {
headers(headers)
}
return this
}
private fun Response.ensureSuccess(): Response {
val exception: Exception? = when (code) { // Catch some error codes, not all
HttpURLConnection.HTTP_NOT_FOUND -> NotFoundException(message, request.url.toString())
HttpURLConnection.HTTP_UNAUTHORIZED -> request.tag(MangaSource::class.java)?.let {
AuthRequiredException(it)
} ?: HttpStatusException(message, code, request.url.toString())
in 400..599 -> HttpStatusException(message, code, request.url.toString())
else -> null
}
if (exception != null) {
runCatching {
close()
}.onFailure {
exception.addSuppressed(it)
}
throw exception
}
return this
}
}

@ -1,17 +0,0 @@
package org.koitharu.kotatsu.parsers.network
public object UserAgents {
public const val CHROME_MOBILE: String =
"Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.196 Mobile Safari/537.36"
public const val FIREFOX_MOBILE: String =
"Mozilla/5.0 (Android 14; Mobile; LG-M255; rv:123.0) Gecko/123.0 Firefox/123.0"
public const val CHROME_DESKTOP: String =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
public const val FIREFOX_DESKTOP: String = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/116.0"
public const val KOTATSU: String = "Kotatsu/6.8 (Android 13;;; en)"
}

@ -1,117 +0,0 @@
package org.koitharu.kotatsu.parsers.network
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response
import org.json.JSONObject
public interface WebClient {
/**
* Do a GET http request to specific url
* @param url
*/
public suspend fun httpGet(url: String): Response = httpGet(url.toHttpUrl())
public suspend fun httpGet(url: String, extraHeaders: Headers?): Response = httpGet(url.toHttpUrl(), extraHeaders)
/**
* Do a GET http request to specific url
* @param url
*/
public suspend fun httpGet(url: HttpUrl): Response = httpGet(url, null)
/**
* Do a GET http request to specific url
* @param url
* @param extraHeaders additional HTTP headers for request
*/
public suspend fun httpGet(url: HttpUrl, extraHeaders: Headers?): Response
/**
* Do a HEAD http request to specific url
* @param url
*/
public suspend fun httpHead(url: String): Response = httpHead(url.toHttpUrl())
/**
* Do a HEAD http request to specific url
* @param url
*/
public suspend fun httpHead(url: HttpUrl): Response
/**
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param form payload as key=>value map
*/
public suspend fun httpPost(url: String, form: Map<String, String>): Response =
httpPost(url.toHttpUrl(), form, null)
/**
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param form payload as key=>value map
*/
public suspend fun httpPost(url: HttpUrl, form: Map<String, String>): Response = httpPost(url, form, null)
/**
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param form payload as key=>value map
* @param extraHeaders additional HTTP headers for request
*/
public suspend fun httpPost(url: HttpUrl, form: Map<String, String>, extraHeaders: Headers?): Response
/**
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param payload payload as `key=value` string with `&` separator
*/
public suspend fun httpPost(url: String, payload: String): Response = httpPost(url.toHttpUrl(), payload, null)
/**
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param payload payload as `key=value` string with `&` separator
*/
public suspend fun httpPost(url: HttpUrl, payload: String): Response = httpPost(url, payload, null)
/**
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param payload payload as `key=value` string with `&` separator
* @param extraHeaders additional HTTP headers for request
*/
public suspend fun httpPost(url: HttpUrl, payload: String, extraHeaders: Headers?): Response
/**
* Do a POST http request to specific url with json payload
* @param url
* @param body
*/
public suspend fun httpPost(url: String, body: JSONObject): Response = httpPost(url.toHttpUrl(), body, null)
/**
* Do a POST http request to specific url with json payload
* @param url
* @param body
*/
public suspend fun httpPost(url: HttpUrl, body: JSONObject): Response = httpPost(url, body, null)
/**
* Do a POST http request to specific url with json payload
* @param url
* @param body
* @param extraHeaders additional HTTP headers for request
*/
public suspend fun httpPost(url: HttpUrl, body: JSONObject, extraHeaders: Headers?): Response
/**
* Do a GraphQL request to specific url
* @param endpoint an url
* @param query GraphQL request payload
*/
public suspend fun graphQLQuery(endpoint: String, query: String): JSONObject
}

@ -1,69 +1,50 @@
package org.koitharu.kotatsu.parsers.site.be
package org.koitharu.kotatsu.parsers.site
import androidx.collection.ArraySet
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.Broken
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.generateUid
import org.koitharu.kotatsu.parsers.util.getDomain
import org.koitharu.kotatsu.parsers.util.json.asTypedList
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.json.stringIterator
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import java.util.*
@Broken
@MangaSourceParser("ANIBEL", "Anibel", "be")
internal class AnibelParser(context: MangaLoaderContext) : AbstractMangaParser(context, MangaParserSource.ANIBEL) {
internal class AnibelParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.ANIBEL) {
override val configKeyDomain = ConfigKey.Domain("anibel.net")
override val configKeyDomain = ConfigKey.Domain("anibel.net", null)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities(
isMultipleTagsSupported = true,
isSearchSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = fetchAvailableTags(),
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST,
)
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
override fun getFaviconUrl(): String {
return "https://cdn.${getDomain()}/favicons/favicon.png"
}
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST,
)
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val filters = when {
!filter.query.isNullOrEmpty() -> {
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
if (!query.isNullOrEmpty()) {
return if (offset == 0) {
search(filter.query)
search(query)
} else {
emptyList()
}
}
else -> {
filter.tags.takeUnless { it.isEmpty() }?.joinToString(
val filters = tags?.takeUnless { it.isEmpty() }?.joinToString(
separator = ",",
prefix = "genres: [",
postfix = "]",
) { "\"${it.key}\"" }.orEmpty()
}
}
val array = apiCall(
"""
getMediaList(offset: $offset, limit: 20, mediaType: manga, filters: {$filters}) {
@ -92,12 +73,12 @@ internal class AnibelParser(context: MangaLoaderContext) : AbstractMangaParser(c
title = title.getString("be"),
coverUrl = jo.getString("poster").removePrefix("/cdn")
.toAbsoluteUrl(getDomain("cdn")) + "?width=200&height=280",
altTitles = setOfNotNull(title.optJSONArray("alt")?.optString(0)?.nullIfEmpty()),
authors = emptySet(),
contentRating = null,
altTitle = title.getString("alt").takeUnless(String::isEmpty),
author = null,
isNsfw = false,
rating = jo.getDouble("rating").toFloat() / 10f,
url = href,
publicUrl = "https://${domain}/$href",
publicUrl = "https://${getDomain()}/$href",
tags = jo.getJSONArray("genres").mapToTags(),
state = when (jo.getString("status")) {
"ongoing" -> MangaState.ONGOING
@ -130,21 +111,20 @@ internal class AnibelParser(context: MangaLoaderContext) : AbstractMangaParser(c
""".trimIndent(),
).getJSONObject("media")
val title = details.getJSONObject("title")
val poster = details.getString("poster").removePrefix("/cdn").toAbsoluteUrl(getDomain("cdn"))
val poster = details.getString("poster").removePrefix("/cdn")
.toAbsoluteUrl(getDomain("cdn"))
val chapters = apiCall(
"""
chapters(mediaId: "${details.getString("mediaId")}", offset: 0, limit: 99999) {
docs {
chapters(mediaId: "${details.getString("mediaId")}") {
id
chapter
released
}
}
""".trimIndent(),
).getJSONObject("chapters").getJSONArray("docs")
).getJSONArray("chapters")
return manga.copy(
title = title.getString("be"),
altTitles = setOfNotNull(title.optJSONArray("alt")?.optString(0)?.nullIfEmpty()),
altTitle = title.getString("alt"),
coverUrl = "$poster?width=200&height=280",
largeCoverUrl = poster,
description = details.getJSONObject("description").getString("be"),
@ -159,9 +139,8 @@ internal class AnibelParser(context: MangaLoaderContext) : AbstractMangaParser(c
val number = jo.getInt("chapter")
MangaChapter(
id = generateUid(jo.getString("id")),
title = null,
number = number.toFloat(),
volume = 0,
name = "Глава $number",
number = number,
url = "${manga.url}/read/$number",
scanlator = null,
uploadDate = jo.getLong("released"),
@ -186,17 +165,19 @@ internal class AnibelParser(context: MangaLoaderContext) : AbstractMangaParser(c
""".trimIndent(),
).getJSONObject("chapter")
val pages = chapterJson.getJSONArray("images")
val chapterUrl = "https://${getDomain()}/${chapter.url}"
return pages.mapJSONIndexed { i, jo ->
MangaPage(
id = generateUid("${chapter.url}/$i"),
url = jo.getString("large"),
referer = chapterUrl,
preview = jo.getString("thumbnail"),
source = source,
)
}
}
private suspend fun fetchAvailableTags(): Set<MangaTag> {
override suspend fun getTags(): Set<MangaTag> {
val json = apiCall(
"""
getFilters(mediaType: manga) {
@ -211,53 +192,44 @@ internal class AnibelParser(context: MangaLoaderContext) : AbstractMangaParser(c
private suspend fun search(query: String): List<Manga> {
val json = apiCall(
"""
search(query: "$query", limit: 60) {
mediaId
search(query: "$query", limit: 40) {
id
title {
be
en
}
poster
status
slug
mediaType
genres
url
type
}
""".trimIndent(),
)
val array = json.getJSONArray("search")
return array.mapJSONNotNull { jo ->
val type = jo.getString("mediaType").lowercase()
if (type != "manga") {
return@mapJSONNotNull null
}
val mediaId = jo.getString("mediaId")
return array.mapJSON { jo ->
val mediaId = jo.getString("id")
val title = jo.getJSONObject("title")
val href = "$type/${jo.getString("slug")}"
val href = "${jo.getString("type").lowercase()}/${jo.getString("url")}"
Manga(
id = generateUid(mediaId),
title = title.getString("be"),
coverUrl = jo.getString("poster").removePrefix("/cdn")
.toAbsoluteUrl(getDomain("cdn")) + "?width=200&height=280",
altTitles = setOfNotNull(title.getString("en").nullIfEmpty()),
authors = emptySet(),
contentRating = null,
altTitle = title.getString("en").takeUnless(String::isEmpty),
author = null,
isNsfw = false,
rating = RATING_UNKNOWN,
url = href,
publicUrl = "https://${domain}/$href",
tags = jo.getJSONArray("genres").mapToTags(),
state = when (jo.getString("status")) {
"ongoing" -> MangaState.ONGOING
"finished" -> MangaState.FINISHED
else -> null
},
publicUrl = "https://${getDomain()}/$href",
tags = emptySet(),
state = null,
source = source,
)
}
}
private suspend fun apiCall(request: String): JSONObject {
return webClient.graphQLQuery("https://${domain}/graphql", request).getJSONObject("data")
return context.graphQLQuery("https://api.${getDomain()}/graphql", request)
.getJSONObject("data")
}
private fun JSONArray.mapToTags(): Set<MangaTag> {
@ -270,7 +242,6 @@ internal class AnibelParser(context: MangaLoaderContext) : AbstractMangaParser(c
c == '-' -> {
builder.setCharAt(i, ' ')
}
capitalize -> {
builder.setCharAt(i, c.uppercaseChar())
capitalize = false
@ -281,7 +252,7 @@ internal class AnibelParser(context: MangaLoaderContext) : AbstractMangaParser(c
}
val result = ArraySet<MangaTag>(length())
asTypedList<String>().forEach {
stringIterator().forEach {
result.add(
MangaTag(
title = toTitle(it),

@ -0,0 +1,310 @@
package org.koitharu.kotatsu.parsers.site
import androidx.collection.ArraySet
import org.json.JSONArray
import org.json.JSONObject
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.*
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
private const val PAGE_SIZE = 60
private const val PAGE_SIZE_SEARCH = 20
@MangaSourceParser("BATOTO", "Bato.To")
internal class BatoToParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.BATOTO) {
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST,
SortOrder.UPDATED,
SortOrder.POPULARITY,
SortOrder.ALPHABETICAL,
)
override val configKeyDomain = ConfigKey.Domain(
"bato.to",
arrayOf("bato.to", "mto.to", "mangatoto.com", "battwo.com", "batotwo.com", "comiko.net", "batotoo.com"),
)
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
if (!query.isNullOrEmpty()) {
return search(offset, query)
}
val page = (offset / PAGE_SIZE) + 1
@Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT")
val url = buildString {
append("https://")
append(getDomain())
append("/browse?sort=")
when (sortOrder) {
SortOrder.UPDATED,
-> append("update.za")
SortOrder.POPULARITY -> append("views_a.za")
SortOrder.NEWEST -> append("create.za")
SortOrder.ALPHABETICAL -> append("title.az")
}
if (!tags.isNullOrEmpty()) {
append("&genres=")
appendAll(tags, ",") { it.key }
}
append("&page=")
append(page)
}
return parseList(url, page)
}
override suspend fun getDetails(manga: Manga): Manga {
val root = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
.getElementById("mainer") ?: parseFailed("Cannot find root")
val details = root.selectFirst(".detail-set") ?: parseFailed("Cannot find detail-set")
val attrs = details.selectFirst(".attr-main")?.select(".attr-item")?.associate {
it.child(0).text().trim() to it.child(1)
}.orEmpty()
return manga.copy(
title = root.selectFirst("h3.item-title")?.text() ?: manga.title,
isNsfw = !root.selectFirst("alert")?.getElementsContainingOwnText("NSFW").isNullOrEmpty(),
largeCoverUrl = details.selectFirst("img[src]")?.absUrl("src"),
description = details.getElementById("limit-height-body-summary")
?.selectFirst(".limit-html")
?.html(),
tags = manga.tags + attrs["Genres:"]?.parseTags().orEmpty(),
state = when (attrs["Release status:"]?.text()) {
"Ongoing" -> MangaState.ONGOING
"Completed" -> MangaState.FINISHED
else -> manga.state
},
author = attrs["Authors:"]?.text()?.trim() ?: manga.author,
chapters = root.selectFirst(".episode-list")
?.selectFirst(".main")
?.children()
?.reversed()
?.mapIndexedNotNull { i, div ->
div.parseChapter(i)
}.orEmpty(),
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
val scripts = context.httpGet(fullUrl).parseHtml().select("script")
for (script in scripts) {
val scriptSrc = script.html()
val p = scriptSrc.indexOf("const images =")
if (p == -1) continue
val start = scriptSrc.indexOf('[', p)
val end = scriptSrc.indexOf(';', start)
if (start == -1 || end == -1) {
continue
}
val images = JSONArray(scriptSrc.substring(start, end))
val batoJs = scriptSrc.substringBetweenFirst("batojs =", ";")?.trim(' ', '"', '\n')
?: parseFailed("Cannot find batojs")
val server = scriptSrc.substringBetweenFirst("server =", ";")?.trim(' ', '"', '\n')
?: parseFailed("Cannot find server")
val password = context.evaluateJs(batoJs)?.removeSurrounding('"')
?: parseFailed("Cannot evaluate batojs")
val serverDecrypted = decryptAES(server, password).removeSurrounding('"')
val result = ArrayList<MangaPage>(images.length())
repeat(images.length()) { i ->
val url = images.getString(i)
result += MangaPage(
id = generateUid(url),
url = if (url.startsWith("http")) url else "$serverDecrypted$url",
referer = fullUrl,
preview = null,
source = source,
)
}
return result
}
parseFailed("Cannot find images list")
}
override suspend fun getTags(): Set<MangaTag> {
val scripts = context.httpGet(
"https://${getDomain()}/browse",
).parseHtml().select("script")
for (script in scripts) {
val genres = script.html().substringBetweenFirst("const _genres =", ";") ?: continue
val jo = JSONObject(genres)
val result = ArraySet<MangaTag>(jo.length())
jo.keys().forEach { key ->
val item = jo.getJSONObject(key)
result += MangaTag(
title = item.getString("text").toTitleCase(),
key = item.getString("file"),
source = source,
)
}
return result
}
parseFailed("Cannot find gernes list")
}
override fun getFaviconUrl(): String = "https://styles.amarkcdn.com/img/batoto/favicon.ico?v0"
private suspend fun search(offset: Int, query: String): List<Manga> {
val page = (offset / PAGE_SIZE_SEARCH) + 1
val url = buildString {
append("https://")
append(getDomain())
append("/search?word=")
append(query.replace(' ', '+'))
append("&page=")
append(page)
}
return parseList(url, page)
}
private fun getActivePage(body: Element): Int = body.select("nav ul.pagination > li.page-item.active")
.lastOrNull()
?.text()
?.toIntOrNull() ?: parseFailed("Cannot determine current page")
private suspend fun parseList(url: String, page: Int): List<Manga> {
val body = context.httpGet(url).parseHtml().body()
if (body.selectFirst(".browse-no-matches") != null) {
return emptyList()
}
val activePage = getActivePage(body)
if (activePage != page) {
return emptyList()
}
val root = body.getElementById("series-list") ?: parseFailed("Cannot find root")
return root.children().map { div ->
val a = div.selectFirst("a") ?: parseFailed()
val href = a.attrAsRelativeUrl("href")
val title = div.selectFirst(".item-title")?.text() ?: parseFailed("Title not found")
Manga(
id = generateUid(href),
title = title,
altTitle = div.selectFirst(".item-alias")?.text()?.takeUnless { it == title },
url = href,
publicUrl = a.absUrl("href"),
rating = RATING_UNKNOWN,
isNsfw = false,
coverUrl = div.selectFirst("img[src]")?.absUrl("src").orEmpty(),
largeCoverUrl = null,
description = null,
tags = div.selectFirst(".item-genre")?.parseTags().orEmpty(),
state = null,
author = null,
source = source,
)
}
}
private fun Element.parseTags() = children().mapToSet { span ->
val text = span.ownText()
MangaTag(
title = text.toTitleCase(),
key = text.lowercase(Locale.ENGLISH).replace(' ', '_'),
source = source,
)
}
private fun Element.parseChapter(index: Int): MangaChapter? {
val a = selectFirst("a.chapt") ?: return null
val extra = selectFirst(".extra")
val href = a.attrAsRelativeUrl("href")
return MangaChapter(
id = generateUid(href),
name = a.text(),
number = index + 1,
url = href,
scanlator = extra?.getElementsByAttributeValueContaining("href", "/group/")?.text(),
uploadDate = runCatching {
parseChapterDate(extra?.select("i")?.lastOrNull()?.ownText())
}.getOrDefault(0),
branch = null,
source = source,
)
}
private fun parseChapterDate(date: String?): Long {
if (date.isNullOrEmpty()) {
return 0
}
val value = date.substringBefore(' ').toInt()
val field = when {
"sec" in date -> Calendar.SECOND
"min" in date -> Calendar.MINUTE
"hour" in date -> Calendar.HOUR
"day" in date -> Calendar.DAY_OF_MONTH
"week" in date -> Calendar.WEEK_OF_YEAR
"month" in date -> Calendar.MONTH
"year" in date -> Calendar.YEAR
else -> return 0
}
val calendar = Calendar.getInstance()
calendar.add(field, -value)
return calendar.timeInMillis
}
private fun decryptAES(encrypted: String, password: String): String {
val cipherData = context.decodeBase64(encrypted)
val saltData = cipherData.copyOfRange(8, 16)
val (key, iv) = generateKeyAndIV(
keyLength = 32,
ivLength = 16,
iterations = 1,
salt = saltData,
password = password.toByteArray(StandardCharsets.UTF_8),
md = MessageDigest.getInstance("MD5"),
)
val encryptedData = cipherData.copyOfRange(16, cipherData.size)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, iv)
return cipher.doFinal(encryptedData).toString(Charsets.UTF_8)
}
@Suppress("SameParameterValue")
private fun generateKeyAndIV(
keyLength: Int,
ivLength: Int,
iterations: Int,
salt: ByteArray,
password: ByteArray,
md: MessageDigest,
): Pair<SecretKeySpec, IvParameterSpec> {
val digestLength = md.digestLength
val requiredLength = (keyLength + ivLength + digestLength - 1) / digestLength * digestLength
val generatedData = ByteArray(requiredLength)
var generatedLength = 0
md.reset()
while (generatedLength < keyLength + ivLength) {
if (generatedLength > 0) {
md.update(generatedData, generatedLength - digestLength, digestLength)
}
md.update(password)
md.update(salt, 0, 8)
md.digest(generatedData, generatedLength, digestLength)
repeat(iterations - 1) {
md.update(generatedData, generatedLength, digestLength)
md.digest(generatedData, generatedLength, digestLength)
}
generatedLength += digestLength
}
return SecretKeySpec(generatedData.copyOfRange(0, keyLength), "AES") to IvParameterSpec(
if (ivLength > 0) {
generatedData.copyOfRange(keyLength, keyLength + ivLength)
} else byteArrayOf(),
)
}
}

@ -0,0 +1,163 @@
package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
internal abstract class ChanParser(source: MangaSource) : MangaParser(source) {
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST,
SortOrder.POPULARITY,
SortOrder.ALPHABETICAL,
)
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val domain = getDomain()
val url = when {
!query.isNullOrEmpty() -> {
if (offset != 0) {
return emptyList()
}
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
}
!tags.isNullOrEmpty() -> tags.joinToString(
prefix = "https://$domain/tags/",
postfix = "&n=${getSortKey2(sortOrder)}?offset=$offset",
separator = "+",
) { tag -> tag.key }
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
}
val doc = context.httpGet(url).parseHtml()
val root = doc.body().selectFirst("div.main_fon")?.getElementById("content")
?: parseFailed("Cannot find root")
return root.select("div.content_row").mapNotNull { row ->
val a = row.selectFirst("div.manga_row1")?.selectFirst("h2")?.selectFirst("a")
?: return@mapNotNull null
val href = a.attrAsRelativeUrl("href")
Manga(
id = generateUid(href),
url = href,
publicUrl = href.toAbsoluteUrl(a.host ?: domain),
altTitle = a.attr("title"),
title = a.text().substringAfterLast('(').substringBeforeLast(')'),
author = row.getElementsByAttributeValueStarting(
"href",
"/mangaka",
).firstOrNull()?.text(),
coverUrl = row.selectFirst("div.manga_images")?.selectFirst("img")
?.absUrl("src").orEmpty(),
tags = runCatching {
row.selectFirst("div.genre")?.select("a")?.mapToSet {
MangaTag(
title = it.text().toTagName(),
key = it.attr("href").substringAfterLast('/').urlEncoded(),
source = source,
)
}
}.getOrNull().orEmpty(),
rating = RATING_UNKNOWN,
state = null,
isNsfw = false,
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
val root =
doc.body().getElementById("dle-content") ?: parseFailed("Cannot find root")
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
chapters = root.select("table.table_cha tr:gt(1)").reversed().mapIndexedNotNull { i, tr ->
val href = tr?.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
?: return@mapIndexedNotNull null
MangaChapter(
id = generateUid(href),
name = tr.selectFirst("a")?.text().orEmpty(),
number = i + 1,
url = href,
scanlator = null,
branch = null,
uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()),
source = source,
)
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
val doc = context.httpGet(fullUrl).parseHtml()
val scripts = doc.select("script")
for (script in scripts) {
val data = script.html()
val pos = data.indexOf("\"fullimg")
if (pos == -1) {
continue
}
val json = data.substring(pos).substringAfter('[').substringBefore(';')
.substringBeforeLast(']')
val domain = getDomain()
return json.split(",").mapNotNull {
it.trim()
.removeSurrounding('"', '\'')
.toRelativeUrl(domain)
.takeUnless(String::isBlank)
}.map { url ->
MangaPage(
id = generateUid(url),
url = url,
preview = null,
referer = fullUrl,
source = source,
)
}
}
parseFailed("Pages list not found at ${chapter.url}")
}
override suspend fun getTags(): Set<MangaTag> {
val domain = getDomain()
val doc = context.httpGet("https://$domain/mostfavorites&sort=manga").parseHtml()
val root = doc.body().selectFirst("div.main_fon")?.getElementById("side")
?.select("ul")?.last() ?: throw ParseException("Cannot find root")
return root.select("li.sidetag").mapToSet { li ->
val a = li.children().last() ?: throw ParseException("a is null")
MangaTag(
title = a.text().toTagName(),
key = a.attr("href").substringAfterLast('/'),
source = source,
)
}
}
private fun getSortKey(sortOrder: SortOrder) =
when (sortOrder) {
SortOrder.ALPHABETICAL -> "catalog"
SortOrder.POPULARITY -> "mostfavorites"
SortOrder.NEWEST -> "manga/new"
else -> "mostfavorites"
}
private fun getSortKey2(sortOrder: SortOrder) =
when (sortOrder) {
SortOrder.ALPHABETICAL -> "abcasc"
SortOrder.POPULARITY -> "favdesc"
SortOrder.NEWEST -> "datedesc"
else -> "favdesc"
}
private fun String.toTagName() = replace('_', ' ').toTitleCase()
}

@ -0,0 +1,216 @@
package org.koitharu.kotatsu.parsers.site
import androidx.collection.ArraySet
import androidx.collection.SparseArrayCompat
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.*
import java.text.SimpleDateFormat
import java.util.*
/**
* https://api.comick.fun/docs/static/index.html
*/
private const val PAGE_SIZE = 20
private const val CHAPTERS_LIMIT = 99999
@MangaSourceParser("COMICK_FUN", "ComicK")
internal class ComickFunParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.COMICK_FUN) {
override val configKeyDomain = ConfigKey.Domain("comick.fun", null)
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY,
SortOrder.UPDATED,
SortOrder.RATING,
)
@Volatile
private var cachedTags: SparseArrayCompat<MangaTag>? = null
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val domain = getDomain()
val url = buildString {
append("https://api.")
append(domain)
append("/search?tachiyomi=true")
if (!query.isNullOrEmpty()) {
if (offset > 0) {
return emptyList()
}
append("&q=")
append(query.urlEncoded())
} else {
append("&limit=")
append(PAGE_SIZE)
append("&page=")
append((offset / PAGE_SIZE) + 1)
if (!tags.isNullOrEmpty()) {
append("&genres=")
appendAll(tags, "&genres=", MangaTag::key)
}
append("&sort=") // view, uploaded, rating, follow, user_follow_count
append(
when (sortOrder) {
SortOrder.POPULARITY -> "view"
SortOrder.RATING -> "rating"
else -> "uploaded"
},
)
}
}
val ja = context.httpGet(url).parseJsonArray()
val tagsMap = cachedTags ?: loadTags()
return ja.mapJSON { jo ->
val slug = jo.getString("slug")
Manga(
id = generateUid(slug),
title = jo.getString("title"),
altTitle = null,
url = slug,
publicUrl = "https://$domain/comic/$slug",
rating = jo.getDoubleOrDefault("rating", -10.0).toFloat() / 10f,
isNsfw = false,
coverUrl = jo.getString("cover_url"),
largeCoverUrl = null,
description = jo.getStringOrNull("desc"),
tags = jo.selectGenres("genres", tagsMap),
state = runCatching {
if (jo.getBoolean("translation_completed")) {
MangaState.FINISHED
} else {
MangaState.ONGOING
}
}.getOrNull(),
author = null,
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val domain = getDomain()
val url = "https://api.$domain/comic/${manga.url}?tachiyomi=true"
val jo = context.httpGet(url).parseJson()
val comic = jo.getJSONObject("comic")
return manga.copy(
title = comic.getString("title"),
altTitle = null, // TODO
isNsfw = jo.getBoolean("matureContent") || comic.getBoolean("hentai"),
description = comic.getStringOrNull("parsed") ?: comic.getString("desc"),
tags = manga.tags + jo.getJSONArray("genres").mapJSONToSet {
MangaTag(
title = it.getString("name"),
key = it.getString("slug"),
source = source,
)
},
author = jo.getJSONArray("artists").optJSONObject(0)?.getString("name"),
chapters = getChapters(comic.getLong("id")),
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val jo = context.httpGet(
"https://api.${getDomain()}/chapter/${chapter.url}?tachiyomi=true",
).parseJson().getJSONObject("chapter")
val referer = "https://${getDomain()}/"
return jo.getJSONArray("images").mapJSON {
val url = it.getString("url")
MangaPage(
id = generateUid(url),
url = url,
referer = referer,
preview = null,
source = source,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val sparseArray = cachedTags ?: loadTags()
val set = ArraySet<MangaTag>(sparseArray.size())
for (i in 0 until sparseArray.size()) {
set.add(sparseArray.valueAt(i))
}
return set
}
private suspend fun loadTags(): SparseArrayCompat<MangaTag> {
val ja = context.httpGet("https://api.${getDomain()}/genre").parseJsonArray()
val tags = SparseArrayCompat<MangaTag>(ja.length())
for (jo in ja.JSONIterator()) {
tags.append(
jo.getInt("id"),
MangaTag(
title = jo.getString("name"),
key = jo.getString("slug"),
source = source,
),
)
}
cachedTags = tags
return tags
}
private suspend fun getChapters(id: Long): List<MangaChapter> {
val ja = context.httpGet(
url = "https://api.${getDomain()}/comic/$id/chapter?tachiyomi=true&limit=$CHAPTERS_LIMIT",
).parseJson().getJSONArray("chapters")
val dateFormat = SimpleDateFormat("yyyy-MM-dd")
val counters = HashMap<Locale, Int>()
return ja.mapReversed { jo ->
val locale = Locale.forLanguageTag(jo.getString("lang"))
var number = counters[locale] ?: 0
number++
counters[locale] = number
MangaChapter(
id = generateUid(jo.getLong("id")),
name = buildString {
jo.getStringOrNull("vol")?.let { append("Vol ").append(it).append(' ') }
jo.getStringOrNull("chap")?.let { append("Chap ").append(it) }
jo.getStringOrNull("title")?.let { append(": ").append(it) }
},
number = number,
url = jo.getString("hid"),
scanlator = jo.optJSONArray("group_name")?.optString(0),
uploadDate = dateFormat.tryParse(jo.getString("created_at").substringBefore('T')),
branch = locale.getDisplayName(locale).toTitleCase(locale),
source = source,
)
}
}
private inline fun <R> JSONArray.mapReversed(block: (JSONObject) -> R): List<R> {
val len = length()
val destination = ArrayList<R>(len)
for (i in (0 until len).reversed()) {
val jo = getJSONObject(i)
destination.add(block(jo))
}
return destination
}
private fun JSONObject.selectGenres(name: String, tags: SparseArrayCompat<MangaTag>): Set<MangaTag> {
val array = optJSONArray(name) ?: return emptySet()
val res = ArraySet<MangaTag>(array.length())
for (i in 0 until array.length()) {
val id = array.getInt(i)
val tag = tags.get(id) ?: continue
res.add(tag)
}
return res
}
}

@ -0,0 +1,160 @@
package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
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.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
import java.util.*
@MangaSourceParser("DESUME", "Desu.me", "ru")
internal class DesuMeParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DESUME) {
override val configKeyDomain = ConfigKey.Domain("desu.me", null)
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.POPULARITY,
SortOrder.NEWEST,
SortOrder.ALPHABETICAL,
)
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
if (query != null && offset != 0) {
return emptyList()
}
val domain = getDomain()
val url = buildString {
append("https://")
append(domain)
append("/manga/api/?limit=20&order=")
append(getSortKey(sortOrder))
append("&page=")
append((offset / 20) + 1)
if (!tags.isNullOrEmpty()) {
append("&genres=")
appendAll(tags, ",") { it.key }
}
if (query != null) {
append("&search=")
append(query)
}
}
val json = context.httpGet(url).parseJson().getJSONArray("response") ?: parseFailed("Invalid response")
val total = json.length()
val list = ArrayList<Manga>(total)
for (i in 0 until total) {
val jo = json.getJSONObject(i)
val cover = jo.getJSONObject("image")
val id = jo.getLong("id")
list += Manga(
url = "/manga/api/$id",
publicUrl = jo.getString("url"),
source = MangaSource.DESUME,
title = jo.getString("russian"),
altTitle = jo.getString("name"),
coverUrl = cover.getString("preview"),
largeCoverUrl = cover.getString("original"),
state = when {
jo.getInt("ongoing") == 1 -> MangaState.ONGOING
else -> null
},
rating = jo.getDouble("score").toFloat().coerceIn(0f, 1f),
id = generateUid(id),
isNsfw = false,
tags = emptySet(),
author = null,
description = jo.getString("description"),
)
}
return list
}
override suspend fun getDetails(manga: Manga): Manga {
val url = manga.url.toAbsoluteUrl(getDomain())
val json = context.httpGet(url).parseJson().getJSONObject("response")
?: throw ParseException("Invalid response")
val baseChapterUrl = manga.url + "/chapter/"
val chaptersList = json.getJSONObject("chapters").getJSONArray("list")
val totalChapters = chaptersList.length()
return manga.copy(
tags = json.getJSONArray("genres").mapJSONToSet {
MangaTag(
key = it.getString("text"),
title = it.getString("russian").toTitleCase(),
source = manga.source,
)
},
publicUrl = json.getString("url"),
description = json.getString("description"),
chapters = chaptersList.mapJSONIndexed { i, it ->
val chid = it.getLong("id")
val volChap = "Том " + it.optString("vol", "0") + ". " + "Глава " + it.optString("ch", "0")
val title = it.optString("title", "null").takeUnless { it == "null" }
MangaChapter(
id = generateUid(chid),
source = manga.source,
url = "$baseChapterUrl$chid",
uploadDate = it.getLong("date") * 1000,
name = if (title.isNullOrEmpty()) volChap else "$volChap: $title",
number = totalChapters - i,
scanlator = null,
branch = null,
)
}.reversed(),
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
val json = context.httpGet(fullUrl)
.parseJson()
.getJSONObject("response") ?: throw ParseException("Invalid response")
return json.getJSONObject("pages").getJSONArray("list").mapJSON { jo ->
MangaPage(
id = generateUid(jo.getLong("id")),
referer = fullUrl,
preview = null,
source = chapter.source,
url = jo.getString("img"),
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}/manga/").parseHtml()
val root = doc.body().getElementById("animeFilter")
?.selectFirst(".catalog-genres") ?: throw ParseException("Root not found")
return root.select("li").mapToSet {
val input = it.selectFirst("input") ?: parseFailed()
MangaTag(
source = source,
key = input.attr("data-genre-slug").ifEmpty {
parseFailed("data-genre-slug is empty")
},
title = input.attr("data-genre-name").toTitleCase().ifEmpty {
parseFailed("data-genre-name is empty")
},
)
}
}
private fun getSortKey(sortOrder: SortOrder) =
when (sortOrder) {
SortOrder.ALPHABETICAL -> "name"
SortOrder.POPULARITY -> "popular"
SortOrder.UPDATED -> "updated"
SortOrder.NEWEST -> "id"
else -> "updated"
}
}

@ -0,0 +1,283 @@
package org.koitharu.kotatsu.parsers.site
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
import kotlin.math.pow
private const val DOMAIN_UNAUTHORIZED = "e-hentai.org"
private const val DOMAIN_AUTHORIZED = "exhentai.org"
@MangaSourceParser("EXHENTAI", "ExHentai")
internal class ExHentaiParser(
override val context: MangaLoaderContext,
) : MangaParser(MangaSource.EXHENTAI), MangaParserAuthProvider {
override val sortOrders: Set<SortOrder> = Collections.singleton(
SortOrder.NEWEST,
)
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain(if (isAuthorized) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED, null)
override val authUrl: String
get() = "https://${getDomain()}/bounce_login.php"
private val ratingPattern = Regex("-?[0-9]+px")
private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash")
private var updateDm = false
override val isAuthorized: Boolean
get() {
val authorized = isAuthorized(DOMAIN_UNAUTHORIZED)
if (authorized) {
if (!isAuthorized(DOMAIN_AUTHORIZED)) {
context.cookieJar.copyCookies(
DOMAIN_UNAUTHORIZED,
DOMAIN_AUTHORIZED,
authCookies,
)
context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "yay=louder")
}
return true
}
return false
}
init {
context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2")
context.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
}
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val page = (offset / 25f).toIntUp()
var search = query?.urlEncoded().orEmpty()
val url = buildString {
append("https://")
append(getDomain())
append("/?page=")
append(page)
if (!tags.isNullOrEmpty()) {
var fCats = 0
for (tag in tags) {
tag.key.toIntOrNull()?.let { fCats = fCats or it } ?: run {
search += tag.key + " "
}
}
if (fCats != 0) {
append("&f_cats=")
append(1023 - fCats)
}
}
if (search.isNotEmpty()) {
append("&f_search=")
append(search.trim().replace(' ', '+'))
}
// by unknown reason cookie "sl=dm_2" is ignored, so, we should request it again
if (updateDm) {
append("&inline_set=dm_e")
}
}
val body = context.httpGet(url).parseHtml().body()
val root = body.selectFirst("table.itg")
?.selectFirst("tbody")
?: if (updateDm) {
parseFailed("Cannot find root")
} else {
updateDm = true
return getList(offset, query, tags, sortOrder)
}
updateDm = false
return root.children().mapNotNull { tr ->
if (tr.childrenSize() != 2) return@mapNotNull null
val (td1, td2) = tr.children()
val glink = td2.selectFirst("div.glink") ?: parseFailed("glink not found")
val a = glink.parents().select("a").first() ?: parseFailed("link not found")
val href = a.attrAsRelativeUrl("href")
val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found")
val mainTag = td2.selectFirst("div.cn")?.let { div ->
MangaTag(
title = div.text().toTitleCase(),
key = tagIdByClass(div.classNames()) ?: return@let null,
source = source,
)
}
Manga(
id = generateUid(href),
title = glink.text().cleanupTitle(),
altTitle = null,
url = href,
publicUrl = a.absUrl("href"),
rating = td2.selectFirst("div.ir")?.parseRating() ?: RATING_UNKNOWN,
isNsfw = true,
coverUrl = td1.selectFirst("img")?.absUrl("src").orEmpty(),
tags = setOfNotNull(mainTag),
state = null,
author = tagsDiv.getElementsContainingOwnText("artist:").first()
?.nextElementSibling()?.text(),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
val root = doc.body().selectFirst("div.gm") ?: parseFailed("Cannot find root")
val cover = root.getElementById("gd1")?.children()?.first()
val title = root.getElementById("gd2")
val taglist = root.getElementById("taglist")
val tabs = doc.body().selectFirst("table.ptt")?.selectFirst("tr")
return manga.copy(
title = title?.getElementById("gn")?.text()?.cleanupTitle() ?: manga.title,
altTitle = title?.getElementById("gj")?.text()?.cleanupTitle() ?: manga.altTitle,
publicUrl = doc.baseUri().ifEmpty { manga.publicUrl },
rating = root.getElementById("rating_label")?.text()
?.substringAfterLast(' ')
?.toFloatOrNull()
?.div(5f) ?: manga.rating,
largeCoverUrl = cover?.styleValueOrNull("background")?.cssUrl(),
description = taglist?.select("tr")?.joinToString("<br>") { tr ->
val (tc, td) = tr.children()
val subtags = td.select("a").joinToString { it.html() }
"<b>${tc.html()}</b> $subtags"
},
chapters = tabs?.select("a")?.findLast { a ->
a.text().toIntOrNull() != null
}?.let { a ->
val count = a.text().toInt()
val chapters = ArrayList<MangaChapter>(count)
for (i in 1..count) {
val url = "${manga.url}?p=${i - 1}"
chapters += MangaChapter(
id = generateUid(url),
name = "${manga.title} #$i",
number = i,
url = url,
uploadDate = 0L,
source = source,
scanlator = null,
branch = null,
)
}
chapters
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain())).parseHtml()
val root = doc.body().getElementById("gdt") ?: parseFailed("Root not found")
return root.select("a").map { a ->
val url = a.attrAsRelativeUrl("href")
MangaPage(
id = generateUid(url),
url = url,
referer = a.absUrl("href"),
preview = null,
source = source,
)
}
}
override suspend fun getPageUrl(page: MangaPage): String {
val doc = context.httpGet(page.url.toAbsoluteUrl(getDomain())).parseHtml()
return doc.body().getElementById("img")?.absUrl("src")
?: parseFailed("Image not found")
}
override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}").parseHtml()
val root = doc.body().getElementById("searchbox")?.selectFirst("table")
?: parseFailed("Root not found")
return root.select("div.cs").mapNotNullToSet { div ->
val id = div.id().substringAfterLast('_').toIntOrNull()
?: return@mapNotNullToSet null
MangaTag(
title = div.text().toTitleCase(),
key = id.toString(),
source = source,
)
}
}
override suspend fun getUsername(): String {
val doc = context.httpGet("https://forums.$DOMAIN_UNAUTHORIZED/").parseHtml().body()
val username = doc.getElementById("userlinks")
?.getElementsByAttributeValueContaining("href", "showuser=")
?.firstOrNull()
?.ownText()
?: if (doc.getElementById("userlinksguest") != null) {
throw AuthRequiredException(source)
} else {
throw ParseException(null)
}
return username
}
private fun isAuthorized(domain: String): Boolean {
val cookies = context.cookieJar.getCookies(domain).mapToSet { x -> x.name }
return authCookies.all { it in cookies }
}
private fun Element.parseRating(): Float {
return runCatching {
val style = requireNotNull(attr("style"))
val (v1, v2) = ratingPattern.find(style)!!.destructured
var p1 = v1.dropLast(2).toInt()
val p2 = v2.dropLast(2).toInt()
if (p2 != -1) {
p1 += 8
}
(80 - p1) / 80f
}.getOrDefault(RATING_UNKNOWN)
}
private fun String.cleanupTitle(): String {
val result = StringBuilder(length)
var skip = false
for (c in this) {
when {
c == '[' -> skip = true
c == ']' -> skip = false
c.isWhitespace() && result.isEmpty() -> continue
!skip -> result.append(c)
}
}
while (result.lastOrNull()?.isWhitespace() == true) {
result.deleteCharAt(result.lastIndex)
}
return result.toString()
}
private fun String.cssUrl(): String? {
val fromIndex = indexOf("url(")
if (fromIndex == -1) {
return null
}
val toIndex = indexOf(')', startIndex = fromIndex)
return if (toIndex == -1) {
null
} else {
substring(fromIndex + 4, toIndex).trim()
}
}
private fun tagIdByClass(classNames: Collection<String>): String? {
val className = classNames.find { x -> x.startsWith("ct") } ?: return null
val num = className.drop(2).toIntOrNull(16) ?: return null
return 2.0.pow(num).toInt().toString()
}
}

@ -0,0 +1,281 @@
package org.koitharu.kotatsu.parsers.site
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response
import org.json.JSONArray
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import java.text.SimpleDateFormat
import java.util.*
private const val PAGE_SIZE = 70
private const val PAGE_SIZE_SEARCH = 50
private const val NSFW_ALERT = "сексуальные сцены"
private const val NOTHING_FOUND = "Ничего не найдено"
internal abstract class GroupleParser(source: MangaSource, userAgent: String) : MangaParser(source) {
private val headers = Headers.Builder()
.add("User-Agent", userAgent)
.build()
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.POPULARITY,
SortOrder.NEWEST,
SortOrder.RATING,
)
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val domain = getDomain()
val doc = when {
!query.isNullOrEmpty() -> context.httpPost(
"https://$domain/search",
mapOf(
"q" to query.urlEncoded(),
"offset" to (offset upBy PAGE_SIZE_SEARCH).toString(),
),
headers,
)
tags.isNullOrEmpty() -> context.httpGet(
"https://$domain/list?sortType=${
getSortKey(sortOrder)
}&offset=${offset upBy PAGE_SIZE}",
headers,
)
tags.size == 1 -> context.httpGet(
"https://$domain/list/genre/${tags.first().key}?sortType=${
getSortKey(sortOrder)
}&offset=${offset upBy PAGE_SIZE}",
headers,
)
offset > 0 -> return emptyList()
else -> advancedSearch(domain, tags)
}.parseHtml().body()
val root = (doc.getElementById("mangaBox") ?: doc.getElementById("mangaResults"))
?: parseFailed("Cannot find root")
val tiles = root.selectFirst("div.tiles.row") ?: if (
root.select(".alert").any { it.ownText() == NOTHING_FOUND }
) {
return emptyList()
} else {
parseFailed("No tiles found")
}
val baseHost = root.baseUri().toHttpUrl().host
return tiles.select("div.tile").mapNotNull { node ->
val imgDiv = node.selectFirst("div.img") ?: return@mapNotNull null
val descDiv = node.selectFirst("div.desc") ?: return@mapNotNull null
if (descDiv.selectFirst("i.fa-user") != null) {
return@mapNotNull null // skip author
}
val href = imgDiv.selectFirst("a")?.attrAsAbsoluteUrlOrNull("href")
if (href == null || href.toHttpUrl().host != baseHost) {
return@mapNotNull null // skip external links
}
val title = descDiv.selectFirst("h3")?.selectFirst("a")?.text()
?: return@mapNotNull null
val tileInfo = descDiv.selectFirst("div.tile-info")
val relUrl = href.toRelativeUrl(baseHost)
Manga(
id = generateUid(relUrl),
url = relUrl,
publicUrl = href,
title = title,
altTitle = descDiv.selectFirst("h4")?.text(),
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original")?.replace("_p.", ".").orEmpty(),
rating = runCatching {
node.selectFirst("div.rating")
?.attr("title")
?.substringBefore(' ')
?.toFloatOrNull()
?.div(10f)
}.getOrNull() ?: RATING_UNKNOWN,
author = tileInfo?.selectFirst("a.person-link")?.text(),
isNsfw = false,
tags = runCatching {
tileInfo?.select("a.element-link")
?.mapToSet {
MangaTag(
title = it.text().toTitleCase(),
key = it.attr("href").substringAfterLast('/'),
source = source,
)
}
}.getOrNull().orEmpty(),
state = when {
node.selectFirst("div.tags")
?.selectFirst("span.mangaCompleted") != null -> MangaState.FINISHED
else -> null
},
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain()), headers).parseHtml()
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?: parseFailed("Cannot find root")
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img")
return manga.copy(
description = root.selectFirst("div.manga-description")?.html(),
largeCoverUrl = coverImg?.attr("data-full"),
coverUrl = coverImg?.attr("data-thumb") ?: manga.coverUrl,
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
.mapNotNull {
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
MangaTag(
title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'),
source = source,
)
},
isNsfw = root.select(".alert-warning").any { it.ownText().contains(NSFW_ALERT) },
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
?.select("tr:has(td > a)")?.asReversed()?.mapIndexedNotNull { i, tr ->
val a = tr.selectFirst("a") ?: return@mapIndexedNotNull null
val href = a.attrAsRelativeUrl("href")
var translators = ""
val translatorElement = a.attr("title")
if (!translatorElement.isNullOrBlank()) {
translators = translatorElement
.replace("(Переводчик),", "&")
.removeSuffix(" (Переводчик)")
}
MangaChapter(
id = generateUid(href),
name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(),
number = i + 1,
url = href,
uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()),
scanlator = translators,
source = source,
branch = null,
)
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain()) + "?mtr=1", headers).parseHtml()
val scripts = doc.select("script")
for (script in scripts) {
val data = script.html()
val pos = data.indexOf("rm_h.initReader(")
if (pos == -1) {
continue
}
val json = data.substring(pos)
.substringAfter('(')
.substringBefore('\n')
.substringBeforeLast(')')
if (json.isEmpty()) {
continue
}
val ja = JSONArray("[$json]")
val pages = ja.getJSONArray(1)
val servers = ja.getJSONArray(4).mapJSON { it.getString("path") }
val serversStr = servers.joinToString("|")
return (0 until pages.length()).map { i ->
val page = pages.getJSONArray(i)
val primaryServer = page.getString(0)
val url = page.getString(2)
MangaPage(
id = generateUid(url),
url = "$primaryServer|$serversStr|$url",
preview = null,
referer = chapter.url,
source = source,
)
}
}
parseFailed("Pages list not found at ${chapter.url}")
}
override suspend fun getPageUrl(page: MangaPage): String {
val parts = page.url.split('|')
val path = parts.last()
val servers = parts.dropLast(1).toSet()
val headers = Headers.headersOf("Referer", page.referer)
for (server in servers) {
val url = server + path
if (tryHead(url, headers)) {
return url
}
}
val fallbackServer = servers.firstOrNull() ?: parseFailed("Cannot find any page url")
return fallbackServer + path
}
override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}/list/genres/sort_name", headers).parseHtml()
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?.selectFirst("table.table") ?: parseFailed("Cannot find root")
return root.select("a.element-link").mapToSet { a ->
MangaTag(
title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'),
source = source,
)
}
}
private fun getSortKey(sortOrder: SortOrder) =
when (sortOrder) {
SortOrder.ALPHABETICAL -> "name"
SortOrder.POPULARITY -> "rate"
SortOrder.UPDATED -> "updated"
SortOrder.NEWEST -> "created"
SortOrder.RATING -> "votes"
}
private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response {
val url = "https://$domain/search/advanced"
// Step 1: map catalog genres names to advanced-search genres ids
val tagsIndex = context.httpGet(url, headers).parseHtml()
.body().selectFirst("form.search-form")
?.select("div.form-group")
?.get(1) ?: parseFailed("Genres filter element not found")
val tagNames = tags.map { it.title.lowercase() }
val payload = HashMap<String, String>()
var foundGenres = 0
tagsIndex.select("li.property").forEach { li ->
val name = li.text().trim().lowercase()
val id = li.selectFirst("input")?.id()
?: parseFailed("Id for tag $name not found")
payload[id] = if (name in tagNames) {
foundGenres++
"in"
} else ""
}
if (foundGenres != tags.size) {
parseFailed("Some genres are not found")
}
// Step 2: advanced search
payload["q"] = ""
payload["s_high_rate"] = ""
payload["s_single"] = ""
payload["s_mature"] = ""
payload["s_completed"] = ""
payload["s_translated"] = ""
payload["s_many_chapters"] = ""
payload["s_wait_upload"] = ""
payload["s_sale"] = ""
payload["years"] = "1900,2099"
payload["+"] = "Искать".urlEncoded()
return context.httpPost(url, payload, headers)
}
private suspend fun tryHead(url: String, headers: Headers): Boolean = runCatching {
context.httpHead(url, headers).isSuccessful
}.getOrDefault(false)
}

@ -0,0 +1,65 @@
package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.parseHtml
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import org.koitharu.kotatsu.parsers.util.toTitleCase
@MangaSourceParser("HENCHAN", "Хентай-тян", "ru")
internal class HenChanParser(override val context: MangaLoaderContext) : ChanParser(MangaSource.HENCHAN) {
override val configKeyDomain = ConfigKey.Domain(
"xxx.hentaichan.live",
arrayOf("xxx.hentaichan.live", "xx.hentaichan.live", "hentaichan.live", "hentaichan.pro"),
)
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
return super.getList(offset, query, tags, sortOrder).map {
it.copy(
coverUrl = it.coverUrl.replace("_blur", ""),
isNsfw = true,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
val root =
doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root")
val readLink = manga.url.replace("manga", "online")
return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
val a = it.children().last() ?: parseFailed("Invalid tag")
MangaTag(
title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'),
source = source,
)
} ?: manga.tags,
chapters = listOf(
MangaChapter(
id = generateUid(readLink),
url = readLink,
source = source,
number = 1,
uploadDate = 0L,
name = manga.title,
scanlator = null,
branch = null,
),
),
)
}
}

@ -0,0 +1,14 @@
package org.koitharu.kotatsu.parsers.site
import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.MangaSource
@MangaSourceParser("HENTAILIB", "HentaiLib", "ru")
internal class HentaiLibParser(context: MangaLoaderContext) : MangaLibParser(context, MangaSource.HENTAILIB) {
override val configKeyDomain = ConfigKey.Domain("hentailib.me", null)
override fun isNsfw(doc: Document) = true
}

@ -0,0 +1,455 @@
package org.koitharu.kotatsu.parsers.site
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
private const val PAGE_SIZE = 12
internal abstract class MadaraParser(
override val context: MangaLoaderContext,
source: MangaSource,
domain: String,
) : MangaParser(source) {
override val configKeyDomain = ConfigKey.Domain(domain, null)
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.POPULARITY,
)
protected open val tagPrefix = "manga-genre/"
protected open val isNsfwSource = false
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val tag = when {
tags.isNullOrEmpty() -> null
tags.size == 1 -> tags.first()
else -> throw IllegalArgumentException("Multiple genres are not supported by this source")
}
val payload = createRequestTemplate()
payload["page"] = (offset / PAGE_SIZE.toFloat()).toIntUp().toString()
payload["vars[meta_key]"] = when (sortOrder) {
SortOrder.POPULARITY -> "_wp_manga_views"
SortOrder.UPDATED -> "_latest_update"
else -> "_wp_manga_views"
}
payload["vars[wp-manga-genre]"] = tag?.key.orEmpty()
payload["vars[s]"] = query.orEmpty()
val doc = context.httpPost(
"https://${getDomain()}/wp-admin/admin-ajax.php",
payload,
).parseHtml()
return doc.select("div.row.c-tabs-item__content").map { div ->
val href = div.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
?: parseFailed("Link not found")
val summary = div.selectFirst(".tab-summary")
Manga(
id = generateUid(href),
url = href,
publicUrl = href.toAbsoluteUrl(div.host ?: getDomain()),
coverUrl = div.selectFirst("img")?.src().orEmpty(),
title = (summary?.selectFirst("h3") ?: summary?.selectFirst("h4"))?.text().orEmpty(),
altTitle = null,
rating = div.selectFirst("span.total_votes")?.ownText()
?.toFloatOrNull()?.div(5f) ?: -1f,
tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text().toTitleCase(),
source = source,
)
}.orEmpty(),
author = summary?.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
state = when (
summary?.selectFirst(".mg_status")?.selectFirst(".summary-content")
?.ownText()?.trim()?.lowercase()
) {
"ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED
else -> null
},
source = source,
isNsfw = isNsfwSource,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}/manga/").parseHtml()
val body = doc.body()
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
val root2 = body.selectFirst("div.genres_wrap")?.selectFirst("ul.list-unstyled")
if (root1 == null && root2 == null) {
parseFailed("Root not found")
}
val list = root1?.select("li").orEmpty() + root2?.select("li").orEmpty()
val keySet = HashSet<String>(list.size)
return list.mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val href = a.attr("href").removeSuffix("/")
.substringAfterLast(tagPrefix, "")
if (href.isEmpty() || !keySet.add(href)) {
return@mapNotNullToSet null
}
MangaTag(
key = href,
title = a.ownText().trim().toTitleCase(),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val fullUrl = manga.url.toAbsoluteUrl(getDomain())
val doc = context.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirst("div.profile-manga")
?.selectFirst("div.summary_content")
?.selectFirst("div.post-content")
?: throw ParseException("Root not found")
val root2 = doc.body().selectFirst("div.content-area")
?.selectFirst("div.c-page")
?: throw ParseException("Root2 not found")
val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US)
return manga.copy(
tags = root.selectFirst("div.genres-content")?.select("a")
?.mapNotNullToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text().toTitleCase(),
source = source,
)
} ?: manga.tags,
description = root2.selectFirst("div.description-summary")
?.selectFirst("div.summary__content")
?.select("p")
?.filterNot { it.ownText().startsWith("A brief description") }
?.joinToString { it.html() },
chapters = root2.select("li").asReversed().mapIndexed { i, li ->
val a = li.selectFirst("a")
val href = a?.attrAsRelativeUrlOrNull("href") ?: parseFailed("Link is missing")
MangaChapter(
id = generateUid(href),
name = a.ownText(),
number = i + 1,
url = href,
uploadDate = parseChapterDate(
dateFormat,
li.selectFirst("span.chapter-release-date i")?.text(),
),
source = source,
scanlator = null,
branch = null,
)
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
val doc = context.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirst("div.main-col-inner")
?.selectFirst("div.reading-content")
?: throw ParseException("Root not found")
return root.select("div.page-break").map { div ->
val img = div.selectFirst("img") ?: parseFailed("Page image not found")
val url = img.src()?.toRelativeUrl(getDomain()) ?: parseFailed("Image src not found")
MangaPage(
id = generateUid(url),
url = url,
preview = null,
referer = fullUrl,
source = source,
)
}
}
private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
date ?: return 0
return when {
date.endsWith(" ago", ignoreCase = true) -> {
parseRelativeDate(date)
}
// Handle translated 'ago' in Portuguese.
date.endsWith(" atrás", ignoreCase = true) -> {
parseRelativeDate(date)
}
// Handle translated 'ago' in Turkish.
date.endsWith(" önce", ignoreCase = true) -> {
parseRelativeDate(date)
}
// Handle 'yesterday' and 'today', using midnight
date.startsWith("year", ignoreCase = true) -> {
Calendar.getInstance().apply {
add(Calendar.DAY_OF_MONTH, -1) // yesterday
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
date.startsWith("today", ignoreCase = true) -> {
Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
date.contains(Regex("""\d(st|nd|rd|th)""")) -> {
// Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it
date.split(" ").map {
if (it.contains(Regex("""\d\D\D"""))) {
it.replace(Regex("""\D"""), "")
} else {
it
}
}
.let { dateFormat.tryParse(it.joinToString(" ")) }
}
else -> dateFormat.tryParse(date)
}
}
// Parses dates in this form:
// 21 hours ago
private fun parseRelativeDate(date: String): Long {
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
val cal = Calendar.getInstance()
return when {
WordSet(
"hari",
"gün",
"jour",
"día",
"dia",
"day",
).anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
WordSet("jam", "saat", "heure", "hora", "hour").anyWordIn(date) -> cal.apply {
add(
Calendar.HOUR,
-number,
)
}.timeInMillis
WordSet("menit", "dakika", "min", "minute", "minuto").anyWordIn(date) -> cal.apply {
add(
Calendar.MINUTE,
-number,
)
}.timeInMillis
WordSet("detik", "segundo", "second").anyWordIn(date) -> cal.apply {
add(
Calendar.SECOND,
-number,
)
}.timeInMillis
WordSet("month").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
WordSet("year").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
else -> 0
}
}
private fun Element.src(): String? {
return absUrl("data-src").ifEmpty {
absUrl("src")
}.takeUnless { it.isEmpty() }
}
private fun createRequestTemplate() =
(
"action=madara_load_more&page=1&template=madara-core%2Fcontent%2Fcontent-search&vars%5Bs%5D=&vars%5B" +
"orderby%5D=meta_value_num&vars%5Bpaged%5D=1&vars%5Btemplate%5D=search&vars%5Bmeta_query" +
"%5D%5B0%5D%5Brelation%5D=AND&vars%5Bmeta_query%5D%5Brelation%5D=OR&vars%5Bpost_type" +
"%5D=wp-manga&vars%5Bpost_status%5D=publish&vars%5Bmeta_key%5D=_latest_update&vars%5Border" +
"%5D=desc&vars%5Bmanga_archives_item_layout%5D=default"
).split('&')
.map {
val pos = it.indexOf('=')
it.substring(0, pos) to it.substring(pos + 1)
}.toMutableMap()
@MangaSourceParser("MANGAREAD", "MangaRead", "en")
class MangaRead(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAREAD, "www.mangaread.org") {
override val tagPrefix = "genres/"
}
@MangaSourceParser("MANGAWEEBS", "MangaWeebs", "en")
class MangaWeebs(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAWEEBS, "mangaweebs.in")
@MangaSourceParser("READMANWHA", "ReadManwha", "en")
class ReadManwha(context: MangaLoaderContext) : MadaraParser(context, MangaSource.READMANWHA, "readmanwha.net")
@MangaSourceParser("MANGATX", "MangaTx", "en")
class MangaTx(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGATX, "mangatx.com")
@MangaSourceParser("MANGAEFFECT", "MangaEffect", "en")
class MangaEffect(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAEFFECT, "mangaeffect.com")
@MangaSourceParser("AQUAMANGA", "AquaManga", "en")
class AquaManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.AQUAMANGA, "aquamanga.com") {
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/2021/03/cropped-cropped-favicon-1-32x32.png"
}
}
@MangaSourceParser("MANGATX_OT", "MangaTx (ot)", "en")
class MangaTxOt(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGATX_OT, "manga-tx.com")
@MangaSourceParser("MANGAROCK", "MangaRock", "en")
class MangaRock(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAROCK, "mangarockteam.com")
@MangaSourceParser("ISEKAISCAN_EU", "IsekaiScan (eu)", "en")
class IsekaiScanEu(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ISEKAISCAN_EU, "isekaiscan.eu")
@MangaSourceParser("ISEKAISCAN", "IsekaiScan", "en")
class IsekaiScan(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ISEKAISCAN, "isekaiscan.com") {
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/2021/10/isekai-scan-02-01-150x150.png"
}
}
@MangaSourceParser("MANGA_KOMI", "MangaKomi", "en")
class MangaKomi(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_KOMI, "mangakomi.io")
@MangaSourceParser("MANGA_3S", "Manga3s", "en")
class Manga3s(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_3S, "manga3s.com")
@MangaSourceParser("MANHWAKOOL", "Manhwa Kool", "en")
class ManhwaKool(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWAKOOL, "manhwakool.com") {
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/2021/10/cropped-logo-kool-32x32.jpeg"
}
}
@MangaSourceParser("TOPMANHUA", "Top Manhua", "en")
class TopManhua(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TOPMANHUA, "www.topmanhua.com") {
override val tagPrefix = "manhua-genre/"
}
@MangaSourceParser("X2MANGA", "X2Manga", "en")
class X2Manga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.X2MANGA, "x2manga.com")
@MangaSourceParser("SKY_MANGA", "Sky Manga", "en")
class SkyManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.SKY_MANGA, "skymanga.xyz") {
override val isNsfwSource = true
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/cropped-sky-tv-1-32x32.png"
}
}
@MangaSourceParser("MANGA_DISTRICT", "Manga District", "en")
class MangaDistrict(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.MANGA_DISTRICT, "mangadistrict.com") {
override val tagPrefix = "publication-genre/"
override val isNsfwSource = true
}
@MangaSourceParser("HENTAI_4FREE", "Hentai4Free", "en")
class Hentai4Free(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.HENTAI_4FREE, "hentai4free.net") {
override val tagPrefix = "hentai-tag/"
override val isNsfwSource = true
override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}/").parseHtml()
val body = doc.body()
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
val list = root1?.select("li").orEmpty()
val keySet = HashSet<String>(list.size)
return list.mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val href = a.attr("href").removeSuffix("/")
.substringAfterLast(tagPrefix, "")
if (href.isEmpty() || !keySet.add(href)) {
return@mapNotNullToSet null
}
MangaTag(
key = href,
title = a.ownText().trim().toTitleCase(),
source = source,
)
}
}
}
@MangaSourceParser("ALLPORN_COMIC", "All Porn Comic", "en")
class AllPornComic(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.ALLPORN_COMIC, "allporncomic.com") {
override val tagPrefix = "porncomic-genre/"
override val isNsfwSource = true
override fun getFaviconUrl(): String {
return "https://cdn.${getDomain()}/wp-content/uploads/2019/01/cropped-cropped-pcround-32x32.png"
}
}
@MangaSourceParser("MANHWA_CHILL", "Manhwa Chill", "en")
class ManhwaChill(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWA_CHILL, "manhwachill.me")
@MangaSourceParser("TREE_MANGA", "Tree Manga", "en")
class TreeManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TREE_MANGA, "treemanga.com") {
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/2017/10/lgoo-treemanga-2-1.jpg"
}
}
@MangaSourceParser("ALLTOPMANGA", "All Top Manga", "en")
class AllTopManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ALLTOPMANGA, "alltopmanga.com") {
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/2021/12/cropped-Screenshot_4-removebg-preview-32x32.png"
}
}
@MangaSourceParser("MANGACV", "Manga Cv", "en")
class MangaCv(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACV, "mangacv.com") {
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/2020/10/cropped-mangaCV-32x32.png"
}
}
@MangaSourceParser("MANGA_MANHUA", "Manga Manhua", "en")
class MangaManhua(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.MANGA_MANHUA, "mangamanhua.online")
@MangaSourceParser("MANGA_247", "247MANGA", "en")
class Manga247(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_247, "247manga.com") {
override val tagPrefix = "manhwa-genre/"
}
@MangaSourceParser("MANGA_365", "365Manga", "en")
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")
@MangaSourceParser("ZINMANGA", "ZINMANGA", "en")
class Zinmanga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ZINMANGA, "zinmanga.com")
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save