Compare commits

..

1 Commits

@ -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,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,7 +24,6 @@ body:
1. First step
2. Second step
3. Issue here
Please use English language
validations:
required: false

@ -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

@ -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:

@ -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,10 +1,10 @@
name: Parsers test for PRs
name: Parsers test
on:
workflow_dispatch:
pull_request:
paths:
- 'src/main/kotlin/org/koitharu/kotatsu/parsers/**'
- 'src/main/kotlin/org/koitharu/kotatsu/parsers/site/*'
permissions:
contents: read
@ -13,17 +13,15 @@ jobs:
build-and-test:
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
- 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" || true
- run: ./gradlew generateTestsReport
- uses: actions/upload-artifact@v3
with:
name: Report
path: build/test-results-html/TEST-org.koitharu.kotatsu.parsers.MangaParserTest.htm

19
.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,11 @@ 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

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.6.21" />
</component>
</project>

@ -1,10 +1,10 @@
# Contributing
The following is a guide for creating Kotatsu parsers. Thanks for taking the time to contribute!
The following is guide for creating a Kotatsu parsers. Thanks for taking the time to contribute!
## Prerequisites
Before you start, please note that the ability to use the following technologies is **required**.
Before you start, please note that the ability to use following technologies is **required**.
- Basic [Android development](https://developer.android.com/)
- [Kotlin](https://kotlinlang.org/)
@ -16,74 +16,69 @@ Before you start, please note that the ability to use the following technologies
- [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.
Kotatsu parsers is not a part of Android application, but you can easily develop and test it directly inside an Android
application project and relocate it to the library project when done.
### Before you start
First, take a look at 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
First, take a look at `kotatsu-parsers` project structure. Each parser is a single class that
extends `MangaParser` class and have a `MangaSourceParser` annotation.
Also pay attention on extensions in `util` package. For example, extensions from `Jsoup` file
should be used instead of existing JSoup functions because they have better nullability support
and improved error messages.
## Writing your parser
So, you want to create a parser, that will provide access to manga from a website.
First, you should explore a website to learn about API availability.
First, you should explore a website for API availability.
If it does not contain any documentation about
API, [explore network requests](https://firefox-source-docs.mozilla.org/devtools-user/):
some websites use AJAX.
some websites use ajax.
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/ru/DesuMeParser.kt)
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/DesuMeParser.kt)
of Json API usage.
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/be/AnibelParser.kt)
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/AnibelParser.kt)
of GraphQL API usage
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/MangaTownParser.kt)
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/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)
If website is based on some engine it is rationally to use common base class for this one (for example, Madara wordress
theme
and the `MadaraParser` class)
### Parser class skeleton
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`
Parser class must have exactly one primary constructor parameter of type `MangaLoaderContext` and have an
`MangaSourceParser` annotation that provides internal name, title and language of a manga source.
All functions in `MangaParser` class are documented. Pay attention to some peculiarities:
- Never hardcode domain. Specify default domain in `configKeyDomain` field and obtain an actual one using `getDomain()`.
- All ids must be unique and domain-independent. Use `generateUid` functions with relative url or some internal id which
is unique across the manga source.
- `sortOrders` set should not be empty. If your source is not support sorting, specify one most relevance value.
- If you cannot obtain direct links to pages images inside `getPages` method, it is ok to use an intermediate url
as `Page.url` and fetch a direct link at `getPageUrl` function.
- `getFaviconUrl` function is deprecated, but Kotatsu application before v4 still use it, so it is recommended to
override it too.
- You can use _asserts_ to check some optional fields. For example. `Manga.author` field is not required, but if your
source provide such information, add `assert(it != null)`. This will not have any effect on production but help to
find issues during unit testing.
- If your source website (or it's api) uses pages for pagination instead of offset you should extend `PagedMangaParser`
instead of `MangaParser`.
- 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.
During the development it is recommended (but not necessary) to write it directly
in the Kotatsu android application project. You can use `core.parser.DummyParser` class as a sandbox. `Dummy` manga
source is available in debug Kotatsu build.
Once the parser is ready you can relocate your code into the `kotatsu-parsers` library project in a `site` package and
create a Pull Request.
Once parser is ready you can relocate your code into `kotatsu-parsers` library project in a `site` package and create a
Pull Request.
### Testing
It is recommended that unit tests be run before submitting a PR.
It is recommended to run unit tests before submitting a PR.
- Temporary modify the `MangaSources` annotation class: specify your parser(s) name(s) and change mode
to `EnumSource.Mode.INCLUDE`
@ -92,5 +87,4 @@ It is recommended that unit tests be run before submitting a PR.
## 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).
If you need a help or have some questions, ask a community in our [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/KotatsuApp/kotatsu-parsers.svg)](https://jitpack.io/#KotatsuApp/kotatsu-parsers) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/kotatsu-parsers) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
## Usage
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 {
@ -36,33 +35,20 @@ JVM and Android applications.
}
```
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/#KotatsuApp/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
See [Android](https://github.com/KotatsuApp/Kotatsu/blob/devel/app/src/main/java/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/env/MangaLoaderContextImpl.kt)
implementation examples.
- [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)
Note that the `MangaSource.LOCAL` and `MangaSource.DUMMY` parsers cannot be instantiated.
## Contribution
@ -70,5 +56,5 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
## DMCA disclaimer
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.
The developers of this application does not have any affiliation with the content available in the app. It is collecting
from the sources freely available through any web browser.

@ -0,0 +1,73 @@
import tasks.ReportGenerateTask
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=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-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.4'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okio:okio:3.2.0'
api 'org.jsoup:jsoup:1.15.2'
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.9.0'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.0'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
testImplementation 'io.webfolder:quickjs:1.1.0'
}
task generateTestsReport(type: ReportGenerateTask)

@ -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")

@ -0,0 +1,13 @@
plugins {
id('org.jetbrains.kotlin.jvm') version '1.6.21'
}
repositories {
mavenCentral()
}
dependencies {
implementation gradleApi()
implementation 'org.simpleframework:simple-xml:2.7.1'
implementation 'com.soywiz.korlibs.korte:korte-jvm:3.0.0-Beta5'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3'
}

@ -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 +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.4-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

47
buildSrc/gradlew vendored

@ -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.

@ -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,7 +0,0 @@
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}

@ -1,6 +1,6 @@
package tasks
import korlibs.template.Template
import com.soywiz.korte.Template
import kotlinx.coroutines.runBlocking
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction
@ -42,13 +42,10 @@ open class ReportGenerateTask : DefaultTask() {
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" }
check(oldValue == null)
}
val failPercent = (testSuite.failures.toDouble() / testSuite.tests * 100.0).roundToInt()

@ -19,16 +19,14 @@ class TestCase {
var failure: Failure? = null
val index by lazy {
name.split('|').getOrNull(0)?.toIntOrNull() ?: 0
name.split('|')[0].toInt()
}
val testName by lazy {
name.split('|').getOrNull(1).orEmpty()
name.split('|')[1]
}
val source by lazy {
name.split('|').getOrNull(2).orEmpty()
name.split('|')[2]
}
fun isValid() = name.count { it == '|' } == 2
}

@ -2,15 +2,13 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1" name="viewport">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js"
integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
</head>
<body class="py-4">
@ -27,12 +25,13 @@
{{ testSuite.errors }} ({{ error_percent }}%)
</div>
</div>
<div class="table-responsive mt-4">
<table class="table table-hover">
<thead class="sticky-top bg-body">
<thead>
<tr>
<th scope="col">Source</th>
{% for test in tests %}
<th class="text-center" scope="col" style="min-width: 5em;">{{ test }}</th>
<th scope="col" style="min-width: 5em;">{{ test }}</th>
{% endfor %}
</tr>
</thead>
@ -47,29 +46,13 @@
</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;">
<td class="table-warning text-center" style="cursor: pointer;"
data-bs-toggle="modal" data-bs-target="#failure_{{ case.hashCode }}">
<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;">
<td class="table-danger text-center" style="cursor: pointer;"
data-bs-toggle="modal" data-bs-target="#failure_{{ case.hashCode }}">
<i data-feather="x"></i>
</td>
{% endif %}
@ -79,14 +62,14 @@
<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>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="Close"></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>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
@ -96,11 +79,13 @@
</tr>
{% endfor %}
</table>
</div>
</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,18 @@ 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) {
@Suppress("DEPRECATION")
fun MangaSource.newParser(context: MangaLoaderContext): MangaParser = when (this) {
""".trimIndent(),
)
@ -80,67 +78,55 @@ 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 +144,15 @@ 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"
val className = classDeclaration.qualifiedName?.asString()
factoryWriter?.write("\tMangaSource.$name -> $className(context)\n")
val deprecationString = if (deprecation != null) {
val reason = deprecation.arguments
.find { it.name?.asString() == "message" }?.value?.toString() ?: "Unknown reason"
"@Deprecated(\"$reason\") "
} else {
""
}
} else ""
val localeComment = localeTitle?.toTitleCase(localeObj)?.let { " /* $it */" }.orEmpty()
sourcesWriter?.write(
"\t$deprecationString$name(\"$title\", $localeString$localeComment, $type, $isBroken),\n",
)
sourcesWriter?.write("\t$deprecationString$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,166 @@
package org.koitharu.kotatsu.parsers
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Response
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
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.exception.NotFoundException
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())
/**
* Do a GET http request to specific url
* @param url
* @param headers an additional headers for request, may be null
*/
suspend fun httpGet(url: HttpUrl, 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 encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
suspend fun httpGet(url: String, headers: Headers? = null): Response {
return httpGet(url.toHttpUrl(), headers)
}
public open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data)
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(): Response {
val exception: Exception? = when (code) { // Catch some error codes, not all
404 -> NotFoundException(message, request.url.toString())
in 500..599 -> HttpStatusException(message, code, request.url.toString())
else -> null
}
if (exception != null) {
runCatching {
close()
}.onFailure {
exception.addSuppressed(it)
}
throw exception
}
return this
}
}

@ -1,88 +1,200 @@
package org.koitharu.kotatsu.parsers
import androidx.annotation.CallSuper
import androidx.annotation.VisibleForTesting
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Interceptor
import org.jsoup.nodes.Element
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.FaviconParser
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
val config by lazy { context.getConfig(source) }
public val filterCapabilities: MangaListFilterCapabilities
public val config: MangaSourceConfig
public val authorizationProvider: MangaParserAuthProvider?
get() = this as? MangaParserAuthProvider
val sourceLocale: Locale?
get() = source.locale?.let { Locale(it) }
/**
* Provide default domain and available alternatives, if any.
*
* Never hardcode domain in requests, use [domain] instead.
* Never hardcode domain in requests, use [getDomain] instead.
*/
protected abstract val configKeyDomain: ConfigKey.Domain
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
internal open val headers: Headers? = null
/**
* Used as fallback if value of `sortOrder` passed to [getList] is null
*/
public val configKeyDomain: ConfigKey.Domain
protected open val defaultSortOrder: SortOrder
get() {
val supported = sortOrders
return SortOrder.values().first { it in supported }
}
public val domain: String
/**
* 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>
@Deprecated("Too complex. Use getList with filter instead")
public suspend fun getList(query: MangaSearchQuery): List<Manga>
/**
* Parse list of manga with search by text query
*
* @param offset starting from 0 and used for pagination.
* @param query search query
*/
open suspend fun getList(offset: Int, query: String): List<Manga> {
return getList(offset, query, null, defaultSortOrder)
}
public suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): 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 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
*/
open 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>
/**
* Returns direct link to the website favicon
*/
@Deprecated(
message = "Use parseFavicons() to get multiple favicons with different size",
replaceWith = ReplaceWith("parseFavicons()"),
)
open fun getFaviconUrl() = "https://${getDomain()}/favicon.ico"
/**
* Parse favicons from the main page of the source`s website
*/
public suspend fun getFavicons(): Favicons
open suspend fun getFavicons(): Favicons {
return FaviconParser(context, getDomain(), headers).parseFavicons()
}
@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.")
}
fun urlBuilder(): HttpUrl.Builder {
return HttpUrl.Builder()
.scheme("https")
.host(getDomain())
}
/**
* 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 Element.parseFailed(message: String? = null): Nothing {
throw ParseException(message, ownerDocument()?.location() ?: baseUri(), null)
}
@InternalParsersApi
protected fun Set<MangaTag>?.oneOrThrowIfMany(): MangaTag? {
return when {
isNullOrEmpty() -> null
size == 1 -> first()
else -> throw IllegalArgumentException("Multiple genres are not supported by this source")
}
}
}

@ -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,
)

@ -0,0 +1,50 @@
package org.koitharu.kotatsu.parsers
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.Paginator
@InternalParsersApi
abstract class PagedMangaParser(
source: MangaSource,
pageSize: Int,
searchPageSize: Int = pageSize,
) : MangaParser(source) {
protected val paginator = Paginator(pageSize)
protected val searchPaginator = Paginator(searchPageSize)
override suspend fun getList(offset: Int, query: String): List<Manga> {
return getList(searchPaginator, offset, query, null, defaultSortOrder)
}
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
return getList(paginator, offset, null, tags, sortOrder ?: defaultSortOrder)
}
@InternalParsersApi
@Deprecated("You should use getListPage for PagedMangaParser", level = DeprecationLevel.HIDDEN)
final override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> = throw UnsupportedOperationException("You should use getListPage for PagedMangaParser")
abstract suspend fun getListPage(page: Int, query: String?, tags: Set<MangaTag>?, sortOrder: SortOrder): List<Manga>
private suspend fun getList(
paginator: Paginator,
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val page = paginator.getPage(offset)
val list = getListPage(page, query, tags, sortOrder)
paginator.onListReceived(offset, page, list.size)
return list
}
}

@ -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 +1,3 @@
package org.koitharu.kotatsu.parsers.exception
public class ContentUnavailableException(message: String) : RuntimeException(message)
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")
}

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

@ -2,8 +2,8 @@ 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(
val shortMessage: String?,
val url: String,
cause: Throwable? = null,
) : RuntimeException("$shortMessage at $url", 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,
}

@ -2,14 +2,13 @@ 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?,
class Favicon internal constructor(
val url: String,
val size: Int,
internal val rel: String?,
) : Comparable<Favicon> {
@JvmField
public val type: String = url.toHttpUrl().pathSegments.last()
val type: String = url.toHttpUrl().pathSegments.last()
.substringAfterLast('.', "").lowercase()
override fun compareTo(other: Favicon): Int {
@ -20,6 +19,30 @@ public data class Favicon(
return relWeightOf(rel).compareTo(relWeightOf(other.rel))
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Favicon
if (url != other.url) return false
if (size != other.size) return false
if (rel != other.rel) return false
return true
}
override fun hashCode(): Int {
var result = url.hashCode()
result = 31 * result + size
result = 31 * result + rel.hashCode()
return result
}
override fun toString(): String {
return "Favicon(size=$size, type='$type', rel='$rel', url='$url')"
}
private fun relWeightOf(rel: String?) = when (rel) {
"apple-touch-icon" -> 1 // Prefer apple-touch-icon because it has a better quality
"mask-icon" -> -1

@ -1,8 +1,8 @@
package org.koitharu.kotatsu.parsers.model
public class Favicons(
class Favicons internal constructor(
favicons: Collection<Favicon>,
@JvmField public val referer: String?,
val referer: String,
) : Collection<Favicon> {
private val icons = favicons.sortedDescending()
@ -18,11 +18,6 @@ public class Favicons(
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
@ -30,7 +25,7 @@ public class Favicons(
* @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? {
fun find(size: Int, types: Set<String>? = null): Favicon? {
if (icons.isEmpty()) {
return null
}
@ -47,13 +42,4 @@ public class Favicons(
}
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 > 0f && rating <= 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),

@ -1,14 +1,13 @@
package org.koitharu.kotatsu.parsers.site.all
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.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
@ -20,242 +19,93 @@ import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
@MangaSourceParser("BATOTO", "Bato.To")
internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
context = context,
source = MangaParserSource.BATOTO,
internal class BatoToParser(override val context: MangaLoaderContext) : PagedMangaParser(
source = MangaSource.BATOTO,
pageSize = 60,
searchPageSize = 20,
), MangaParserAuthProvider {
) {
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val authUrl: String
get() = "https://${domain}/signin"
override suspend fun isAuthorized(): Boolean {
return context.cookieJar.getCookies(domain).any {
it.name.contains("skey")
}
}
override suspend fun getUsername(): String {
val body = webClient.httpGet("https://${domain}/account/profiles").parseHtml().body()
return body.selectFirst("ul.toggleMenu-content:has(.avatar):has(a) div.text-center div")?.text()
?: body.parseFailed("Cannot find username")
}
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.ALPHABETICAL,
SortOrder.UPDATED,
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST,
SortOrder.UPDATED,
SortOrder.POPULARITY,
SortOrder.POPULARITY_YEAR,
SortOrder.POPULARITY_MONTH,
SortOrder.POPULARITY_WEEK,
SortOrder.POPULARITY_TODAY,
SortOrder.POPULARITY_HOUR,
)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities(
isMultipleTagsSupported = true,
isTagsExclusionSupported = true,
isSearchSupported = true,
isOriginalLocaleSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = fetchAvailableTags(),
availableStates = EnumSet.of(
MangaState.ONGOING,
MangaState.FINISHED,
MangaState.ABANDONED,
MangaState.PAUSED,
MangaState.UPCOMING,
),
availableContentRating = EnumSet.of(ContentRating.SAFE),
availableLocales = setOf(
Locale.CHINESE, Locale.ENGLISH, Locale.US, Locale.FRENCH, Locale.GERMAN, Locale.ITALIAN, Locale.JAPANESE,
Locale("af"), Locale("ar"), Locale("az"), Locale("eu"), Locale("be"),
Locale("bn"), Locale("bs"), Locale("bg"), Locale("my"), Locale("km"),
Locale("ceb"), Locale("zh_hk"), Locale("zh_tw"), Locale("hr"), Locale("cs"),
Locale("da"), Locale("nl"), Locale("eo"), Locale("et"), Locale("fil"),
Locale("fi"), Locale("ka"), Locale("el"), Locale("ht"), Locale("he"),
Locale("hi"), Locale("hu"), Locale("id"), Locale("kk"), Locale("ko"),
Locale("lv"), Locale("ms"), Locale("ml"), Locale("mo"), Locale("mn"),
Locale("ne"), Locale("no"), Locale("fa"), Locale("pl"), Locale("pt"),
Locale("pt_br"), Locale("pt_pt"), Locale("ro"), Locale("ru"), Locale("sr"),
Locale("si"), Locale("sk"), Locale("es"), Locale("es_419"), Locale("ta"),
Locale("te"), Locale("th"), Locale("ti"), Locale("tr"), Locale("uk"),
Locale("vi"), Locale("zu"),
),
SortOrder.ALPHABETICAL,
)
override val configKeyDomain = ConfigKey.Domain(
"bato.to",
"batocomic.com",
"batocomic.net",
"batocomic.org",
"batotoo.com",
"batotwo.com",
"battwo.com",
"comiko.net",
"comiko.org",
"mangatoto.com",
"mangatoto.net",
"mangatoto.org",
"readtoto.com",
"readtoto.net",
"readtoto.org",
"dto.to",
"hto.to",
"mto.to",
"wto.to",
"xbato.com",
"xbato.net",
"xbato.org",
"zbato.com",
"zbato.net",
"zbato.org",
"fto.to",
"jto.to",
arrayOf("bato.to", "mto.to", "mangatoto.com", "battwo.com", "batotwo.com", "comiko.net", "batotoo.com"),
)
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
when {
!filter.query.isNullOrEmpty() -> {
return search(page, filter.query)
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
if (!query.isNullOrEmpty()) {
return search(page, query)
}
else -> {
@Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT")
val url = buildString {
append("https://")
append(domain)
append(getDomain())
append("/browse?sort=")
when (order) {
SortOrder.UPDATED -> append("update.za")
when (sortOrder) {
SortOrder.UPDATED,
-> append("update.za")
SortOrder.POPULARITY -> append("views_a.za")
SortOrder.NEWEST -> append("create.za")
SortOrder.ALPHABETICAL -> append("title.az")
SortOrder.POPULARITY_YEAR -> append("views_y.za")
SortOrder.POPULARITY_MONTH -> append("views_m.za")
SortOrder.POPULARITY_WEEK -> append("views_w.za")
SortOrder.POPULARITY_TODAY -> append("views_d.za")
SortOrder.POPULARITY_HOUR -> append("views_h.za")
else -> append("update.za")
}
filter.states.oneOrThrowIfMany()?.let {
append("&release=")
append(
when (it) {
MangaState.ONGOING -> "ongoing"
MangaState.FINISHED -> "completed"
MangaState.ABANDONED -> "cancelled"
MangaState.PAUSED -> "hiatus"
MangaState.UPCOMING -> "pending"
else -> throw IllegalArgumentException("$it not supported")
},
)
}
filter.locale?.let {
append("&langs=")
if (it.language == "in") {
append("id")
} else {
append(it.language)
}
}
filter.originalLocale?.let {
append("&origs=")
if (it.language == "in") {
append("id")
} else {
append(it.language)
}
}
if (!tags.isNullOrEmpty()) {
append("&genres=")
if (filter.tags.isNotEmpty()) {
filter.tags.joinTo(this, ",") { it.key }
appendAll(tags, ",") { it.key }
}
append("|")
if (filter.tagsExclude.isNotEmpty()) {
filter.tagsExclude.joinTo(this, ",") { it.key }
}
if (filter.contentRating.isNotEmpty()) {
filter.contentRating.oneOrThrowIfMany()?.let {
append(
when (it) {
ContentRating.SAFE -> append(",gore,bloody,violence,ecchi,adult,mature,smut,hentai")
else -> append("")
},
)
}
}
append("&page=")
append(page.toString())
append(page)
}
return parseList(url, page)
}
}
}
override suspend fun getDetails(manga: Manga): Manga {
val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
.requireElementById("mainer")
val details = root.selectFirstOrThrow(".detail-set")
val attrs = details.selectFirst(".attr-main")?.select(".attr-item")?.associate {
it.child(0).text() to it.child(1)
it.child(0).text().trim() to it.child(1)
}.orEmpty()
val author = attrs["Authors:"]?.textOrNull()
return manga.copy(
title = root.selectFirst("h3.item-title")?.text() ?: manga.title,
contentRating = if (root.selectFirst("alert")?.getElementsContainingOwnText("NSFW").isNullOrEmpty()) {
ContentRating.ADULT
} else {
ContentRating.SAFE
},
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["Original work:"]?.text()) {
state = when (attrs["Release status:"]?.text()) {
"Ongoing" -> MangaState.ONGOING
"Completed" -> MangaState.FINISHED
"Cancelled" -> MangaState.ABANDONED
"Hiatus" -> MangaState.PAUSED
else -> manga.state
},
authors = author?.let { setOf(it) } ?: manga.authors,
author = attrs["Authors:"]?.text()?.trim() ?: manga.author,
chapters = root.selectFirst(".episode-list")
?.selectFirst(".main")
?.children()
?.mapChapters(reversed = true) { i, div ->
?.reversed()
?.mapChapters { i, div ->
div.parseChapter(i)
}.orEmpty(),
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain)
val scripts = webClient.httpGet(fullUrl).parseHtml().select("script")
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 imgHttps =")
val p = scriptSrc.indexOf("const imgHttpLis =")
if (p == -1) continue
val start = scriptSrc.indexOf('[', p)
val end = scriptSrc.indexOf(';', start)
@ -275,7 +125,8 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
val url = images.getString(i)
result += MangaPage(
id = generateUid(url),
url = if (args.length() == 0) url else url + "?" + args.getString(i),
url = url + "?" + args.getString(i),
referer = fullUrl,
preview = null,
source = source,
)
@ -285,9 +136,9 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
throw ParseException("Cannot find images list", fullUrl)
}
private suspend fun fetchAvailableTags(): Set<MangaTag> {
val scripts = webClient.httpGet(
"https://${domain}/browse",
override suspend fun getTags(): Set<MangaTag> {
val scripts = context.httpGet(
"https://${getDomain()}/browse",
).parseHtml().selectOrThrow("script")
for (script in scripts) {
val genres = script.html().substringBetweenFirst("const _genres =", ";") ?: continue
@ -296,7 +147,7 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
jo.keys().forEach { key ->
val item = jo.getJSONObject(key)
result += MangaTag(
title = item.getString("text").toTitleCase(Locale.ENGLISH),
title = item.getString("text").toTitleCase(),
key = item.getString("file"),
source = source,
)
@ -306,10 +157,12 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
throw ParseException("Cannot find gernes list", scripts[0].baseUri())
}
override fun getFaviconUrl(): String = "https://styles.amarkcdn.com/img/batoto/favicon.ico?v0"
private suspend fun search(page: Int, query: String): List<Manga> {
val url = buildString {
append("https://")
append(domain)
append(getDomain())
append("/search?word=")
append(query.replace(' ', '+'))
append("&page=")
@ -324,7 +177,7 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
?.toIntOrNull() ?: body.parseFailed("Cannot determine current page")
private suspend fun parseList(url: String, page: Int): List<Manga> {
val body = webClient.httpGet(url).parseHtml().body()
val body = context.httpGet(url).parseHtml().body()
if (body.selectFirst(".browse-no-matches") != null) {
return emptyList()
}
@ -340,17 +193,17 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
Manga(
id = generateUid(href),
title = title,
altTitles = setOfNotNull(div.selectFirst(".item-alias")?.textOrNull()?.takeUnless { it == title }),
altTitle = div.selectFirst(".item-alias")?.text()?.takeUnless { it == title },
url = href,
publicUrl = a.absUrl("href"),
rating = RATING_UNKNOWN,
contentRating = null,
coverUrl = div.selectFirst("img[src]")?.absUrl("src"),
isNsfw = false,
coverUrl = div.selectFirst("img[src]")?.absUrl("src").orEmpty(),
largeCoverUrl = null,
description = null,
tags = div.selectFirst(".item-genre")?.parseTags().orEmpty(),
state = null,
authors = emptySet(),
author = null,
source = source,
)
}
@ -371,9 +224,8 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
val href = a.attrAsRelativeUrl("href")
return MangaChapter(
id = generateUid(href),
title = a.textOrNull(),
number = index + 1f,
volume = 0,
name = a.text(),
number = index + 1,
url = href,
scanlator = extra?.getElementsByAttributeValueContaining("href", "/group/")?.text(),
uploadDate = runCatching {

@ -0,0 +1,248 @@
package org.koitharu.kotatsu.parsers.site
import androidx.collection.ArrayMap
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.json.JSONArray
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.HashSet
@MangaSourceParser("BLOGTRUYEN", "BlogTruyen", "vi")
class BlogTruyenParser(override val context: MangaLoaderContext) :
PagedMangaParser(MangaSource.BLOGTRUYEN, pageSize = 20) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("blogtruyen.vn", null)
override val sortOrders: Set<SortOrder>
get() = EnumSet.of(SortOrder.UPDATED)
private val mutex = Mutex()
private val dateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US)
private var cacheTags: ArrayMap<String, MangaTag>? = null
override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
val descriptionElement = doc.selectFirstOrThrow("div.description")
val statusText = descriptionElement
.selectFirst("p:contains(Trạng thái) > span.color-red")
?.text()
val state = when (statusText) {
"Đang tiến hành" -> MangaState.ONGOING
"Đã hoàn thành" -> MangaState.FINISHED
else -> null
}
val rating = doc.selectFirst("span.total-vote")?.attr("ng-init")?.let { text ->
val like = text.substringAfter("TotalLike=")
.substringBefore(';')
.toIntOrNull() ?: return@let RATING_UNKNOWN
val dislike = text.substringAfter("TotalDisLike=")
.toIntOrNull() ?: return@let RATING_UNKNOWN
when {
like == 0 && dislike == 0 -> RATING_UNKNOWN
else -> like.toFloat() / (like + dislike)
}
}
val tagMap = getOrCreateTagMap()
val tags = descriptionElement.select("p > span.category").mapNotNullToSet {
val tagName = it.selectFirst("a")?.text()?.trim() ?: return@mapNotNullToSet null
tagMap[tagName]
}
return manga.copy(
tags = tags,
author = descriptionElement.selectFirst("p:contains(Tác giả) > a")?.text(),
description = doc.selectFirst(".detail .content")?.html(),
chapters = parseChapterList(doc),
largeCoverUrl = doc.selectLast("div.thumbnail > img")?.attrAsAbsoluteUrlOrNull("src"),
state = state,
rating = rating ?: RATING_UNKNOWN,
isNsfw = doc.getElementById("warningCategory") != null
)
}
private fun parseChapterList(doc: Document): List<MangaChapter> {
val chapterList = doc.select("#list-chapters > p")
return chapterList.asReversed().mapChapters { index, element ->
val titleElement = element.selectFirst("span.title > a") ?: return@mapChapters null
val name = titleElement.text()
val relativeUrl = titleElement.attrAsRelativeUrl("href")
val id = relativeUrl.substringAfter('/').substringBefore('/')
val uploadDate = dateFormat.tryParse(element.select("span.publishedDate").text())
MangaChapter(
id = generateUid(id),
name = name,
number = index + 1,
url = relativeUrl,
scanlator = null,
uploadDate = uploadDate,
branch = null,
source = source
)
}
}
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
return when {
!query.isNullOrEmpty() -> {
val searchUrl = "https://${getDomain()}/timkiem/nangcao/1/0/-1/-1?txt=${query.urlEncoded()}&p=$page"
val searchContent = context.httpGet(searchUrl).parseHtml()
.selectFirst("section.list-manga-bycate > div.list")
parseMangaList(searchContent)
}
!tags.isNullOrEmpty() -> {
val tag = tags.oneOrThrowIfMany()!!
val categoryAjax = "https://${getDomain()}/ajax/Category/AjaxLoadMangaByCategory?id=${tag.key}&orderBy=5&p=$page"
val listContent = context.httpGet(categoryAjax).parseHtml().selectFirst("div.list")
parseMangaList(listContent)
}
else -> getNormalList(page)
}
}
private suspend fun getNormalList(page: Int): List<Manga> {
val pageLink = "https://${getDomain()}/page-$page"
val doc = context.httpGet(pageLink).parseHtml()
val listElements = doc.selectFirstOrThrow("section.list-mainpage.listview")
.select("div.bg-white.storyitem")
return listElements.mapNotNull {
val linkTag = it.selectFirst("div.fl-l > a") ?: return@mapNotNull null
val relativeUrl = linkTag.attrAsRelativeUrl("href")
val tagMap = getOrCreateTagMap()
val tags = it.select("footer > div.category > a").mapNotNullToSet { a ->
tagMap[a.text()]
}
Manga(
id = generateUid(relativeUrl),
title = linkTag.attr("title"),
altTitle = null,
description = it.selectFirst("p.al-j.break.line-height-15")?.text(),
url = relativeUrl,
publicUrl = relativeUrl.toAbsoluteUrl(getDomain()),
coverUrl = linkTag.selectLast("img")?.attr("src").orEmpty(),
source = source,
tags = tags,
isNsfw = false,
rating = RATING_UNKNOWN,
author = null,
state = null,
)
}
}
private fun parseMangaList(listElement: Element?): List<Manga> {
listElement ?: return emptyList()
return listElement.select("span.tiptip[data-tiptip]").mapNotNull {
val mangaInfo = listElement.getElementById(it.attr("data-tiptip")) ?: return@mapNotNull null
val a = it.selectFirst("a") ?: return@mapNotNull null
val relativeUrl = a.attrAsRelativeUrl("href")
Manga(
id = generateUid(relativeUrl),
title = a.text(),
altTitle = null,
description = mangaInfo.select("div.al-j.fs-12").text(),
url = relativeUrl,
publicUrl = relativeUrl.toAbsoluteUrl(getDomain()),
coverUrl = mangaInfo.selectFirst("div > img.img")?.absUrl("src").orEmpty(),
isNsfw = false,
rating = RATING_UNKNOWN,
tags = emptySet(),
author = null,
state = null,
source = source,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
fun generateImageId(index: Int) = generateUid("${chapter.url}/$index")
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain())).parseHtml()
val pages = ArrayList<MangaPage>()
val referer = chapter.url.toAbsoluteUrl(getDomain())
doc.select("#content > img").forEach { img ->
val url = img.attrAsRelativeUrl("src")
pages.add(
MangaPage(
id = generateImageId(pages.lastIndex),
url = url,
referer = referer,
preview = null,
source = source,
)
)
}
// Some chapters use js script to render images
val script = doc.selectLast("#content > script")
if (script != null && script.data().contains("listImageCaption")) {
val imagesStr = script.data().substringBefore(';').substringAfterLast('=').trim()
val imageArr = JSONArray(imagesStr)
for (i in 0 until imageArr.length()) {
val imageUrl = imageArr.getJSONObject(i).getString("url")
pages.add(
MangaPage(
id = generateImageId(pages.lastIndex),
url = imageUrl,
referer = referer,
preview = null,
source = source
)
)
}
}
return pages
}
override suspend fun getTags(): Set<MangaTag> {
val map = getOrCreateTagMap()
val tags = HashSet<MangaTag>(map.size)
for (entry in map) {
tags.add(entry.value)
}
return tags
}
private suspend fun getOrCreateTagMap(): ArrayMap<String, MangaTag> = mutex.withLock {
cacheTags?.let { return@withLock it }
val doc = context.httpGet("/timkiem/nangcao".toAbsoluteUrl(getDomain())).parseHtml()
val tagItems = doc.select("li[data-id]")
val tagMap = ArrayMap<String, MangaTag>(tagItems.size)
for (tag in tagItems) {
val title = tag.text().trim()
tagMap[tag.text().trim()] = MangaTag(
title = title,
key = tag.attr("data-id"),
source = source
)
}
cacheTags = tagMap
tagMap
}
}

@ -1,58 +1,62 @@
package org.koitharu.kotatsu.parsers.site.en
package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.*
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.SinglePageMangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@MangaSourceParser("CLONEMANGA", "CloneManga", "en")
internal class CloneMangaParser(context: MangaLoaderContext) :
SinglePageMangaParser(context, MangaParserSource.CLONEMANGA) {
internal class CloneMangaParser(override val context: MangaLoaderContext) : PagedMangaParser(
MangaSource.CLONEMANGA,
pageSize = 1,
) {
override val availableSortOrders: Set<SortOrder> = Collections.singleton(
override val sortOrders: Set<SortOrder> = Collections.singleton(
SortOrder.POPULARITY,
)
override val configKeyDomain = ConfigKey.Domain("manga.clone-army.org")
override val configKeyDomain = ConfigKey.Domain("manga.clone-army.org", null)
override val filterCapabilities: MangaListFilterCapabilities get() = MangaListFilterCapabilities()
override suspend fun getFilterOptions() = MangaListFilterOptions()
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
override fun getFaviconUrl(): String {
return "https://pbs.twimg.com/profile_images/458758466346029056/Ys93EANp_400x400.png"
}
override suspend fun getList(order: SortOrder, filter: MangaListFilter): List<Manga> {
val doc = webClient.httpGet("https://$domain/viewer_landing.php").parseHtml()
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
if (query != null || page > 1) {
return emptyList()
}
val link = "https://${getDomain()}/viewer_landing.php"
val doc = context.httpGet(link).parseHtml()
val mangas = doc.getElementsByClass("comicPreviewContainer")
return mangas.mapNotNull { item ->
val background = item.selectFirstOrThrow(".comicPreview").styleValueOrNull("background")
val href = item.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null
val cover = background?.substring(background.indexOf("site/themes"), background.indexOf(")"))
val attr = item.getElementsByClass("comicPreview").attr("style")
val href = item.selectFirst("a")?.attrAsAbsoluteUrl("href") ?: return@mapNotNull null
val cover = attr.substring(attr.indexOf("site/themes"), attr.indexOf(")"))
Manga(
id = generateUid(href),
title = item.selectFirst("h3")?.text() ?: return@mapNotNull null,
coverUrl = "https://$domain/$cover",
altTitles = emptySet(),
authors = setOf("Dan Kim"),
coverUrl = "https://${getDomain()}/$cover",
altTitle = null,
author = "Dan Kim",
rating = RATING_UNKNOWN,
url = href,
contentRating = null,
isNsfw = false,
tags = emptySet(),
state = null,
publicUrl = href.toAbsoluteUrl(domain),
publicUrl = href.toAbsoluteUrl(getDomain()),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.publicUrl).parseHtml()
val doc = context.httpGet(manga.publicUrl).parseHtml()
val series = doc.location()
val numChapters = Regex(
pattern = "&page=(.*)&lang=",
@ -65,14 +69,13 @@ internal class CloneMangaParser(context: MangaLoaderContext) :
for (i in 0..numChapters) {
val chapter = MangaChapter(
id = generateUid("$series&page=$i"),
title = null,
number = i + 1f,
volume = 0,
name = "Chapter ${i + 1}",
number = i + 1,
url = "$series&page=$i",
scanlator = null,
branch = null,
uploadDate = 0L,
source = source,
source = MangaSource.DUMMY,
)
chapters.add(chapter)
}
@ -80,7 +83,7 @@ internal class CloneMangaParser(context: MangaLoaderContext) :
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain())).parseHtml()
val imgUrl = doc.getElementsByClass("subsectionContainer")[0]
.selectFirst("img")
?.attrAsAbsoluteUrlOrNull("src") ?: doc.parseFailed("Something broken")
@ -88,9 +91,14 @@ internal class CloneMangaParser(context: MangaLoaderContext) :
MangaPage(
id = generateUid(imgUrl),
url = imgUrl,
referer = imgUrl,
preview = null,
source = source,
source = MangaSource.DUMMY,
),
)
}
override suspend fun getTags(): Set<MangaTag> {
return emptySet()
}
}

@ -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,161 @@
package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.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) : PagedMangaParser(MangaSource.DESUME, 20) {
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 getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
if (query != null && page != searchPaginator.firstPage) {
return emptyList()
}
val domain = getDomain()
val url = buildString {
append("https://")
append(domain)
append("/manga/api/?limit=20&order=")
append(getSortKey(sortOrder))
append("&page=")
append(page)
if (!tags.isNullOrEmpty()) {
append("&genres=")
appendAll(tags, ",") { it.key }
}
if (query != null) {
append("&search=")
append(query)
}
}
val json = context.httpGet(url).parseJson().getJSONArray("response")
?: throw ParseException("Invalid response", url)
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", url)
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", fullUrl)
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().requireElementById("animeFilter")
.selectFirstOrThrow(".catalog-genres")
return root.select("li").mapToSet {
val input = it.selectFirstOrThrow("input")
MangaTag(
source = source,
key = input.attr("data-genre-slug").ifEmpty {
it.parseFailed("data-genre-slug is empty")
},
title = input.attr("data-genre-name").toTitleCase().ifEmpty {
it.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,279 @@
package org.koitharu.kotatsu.parsers.site
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
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,
) : PagedMangaParser(MangaSource.EXHENTAI, pageSize = 25), 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 getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
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) {
body.parseFailed("Cannot find root")
} else {
updateDm = true
return getListPage(page, 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.selectFirstOrThrow("div.glink")
val a = glink.parents().select("a").first() ?: glink.parseFailed("link not found")
val href = a.attrAsRelativeUrl("href")
val tagsDiv = glink.nextElementSibling() ?: glink.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().selectFirstOrThrow("div.gm")
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 = ChaptersListBuilder(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.toList()
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain())).parseHtml()
val root = doc.body().requireElementById("gdt")
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().requireElementById("img").attrAsAbsoluteUrl("src")
}
override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}").parseHtml()
val root = doc.body().requireElementById("searchbox").selectFirstOrThrow("table")
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 {
doc.parseFailed()
}
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,269 @@
package org.koitharu.kotatsu.parsers.site
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
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.exception.ParseException
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.*
private const val PAGE_SIZE = 20
private const val CHAPTERS_FIRST_PAGE_SIZE = 120
private const val CHAPTERS_MAX_PAGE_SIZE = 500
private const val CHAPTERS_PARALLELISM = 3
private const val CONTENT_RATING =
"contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
private const val LOCALE_FALLBACK = "en"
@MangaSourceParser("MANGADEX", "MangaDex")
internal class MangaDexParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.MANGADEX) {
override val configKeyDomain = ConfigKey.Domain("mangadex.org", null)
override val sortOrders: EnumSet<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.ALPHABETICAL,
SortOrder.NEWEST,
SortOrder.POPULARITY,
)
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("/manga?limit=")
append(PAGE_SIZE)
append("&offset=")
append(offset)
append("&includes[]=cover_art&includes[]=author&includes[]=artist&")
tags?.forEach { tag ->
append("includedTags[]=")
append(tag.key)
append('&')
}
if (!query.isNullOrEmpty()) {
append("title=")
append(query.urlEncoded())
append('&')
}
append(CONTENT_RATING)
append("&order")
append(
when (sortOrder) {
SortOrder.UPDATED,
-> "[latestUploadedChapter]=desc"
SortOrder.ALPHABETICAL -> "[title]=asc"
SortOrder.NEWEST -> "[createdAt]=desc"
SortOrder.POPULARITY -> "[followedCount]=desc"
else -> "[followedCount]=desc"
},
)
}
val json = context.httpGet(url).parseJson().getJSONArray("data")
return json.mapJSON { jo ->
val id = jo.getString("id")
val attrs = jo.getJSONObject("attributes")
val relations = jo.getJSONArray("relationships").associateByKey("type")
val cover = relations["cover_art"]
?.getJSONObject("attributes")
?.getString("fileName")
?.let {
"https://uploads.$domain/covers/$id/$it"
}
Manga(
id = generateUid(id),
title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
"Title should not be null"
},
altTitle = attrs.optJSONObject("altTitles")?.selectByLocale(),
url = id,
publicUrl = "https://$domain/title/$id",
rating = RATING_UNKNOWN,
isNsfw = attrs.getStringOrNull("contentRating") == "erotica",
coverUrl = cover?.plus(".256.jpg").orEmpty(),
largeCoverUrl = cover,
description = attrs.optJSONObject("description")?.selectByLocale(),
tags = attrs.getJSONArray("tags").mapJSONToSet { tag ->
MangaTag(
title = tag.getJSONObject("attributes")
.getJSONObject("name")
.firstStringValue()
.toTitleCase(),
key = tag.getString("id"),
source = source,
)
},
state = when (jo.getStringOrNull("status")) {
"ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED
else -> null
},
author = (relations["author"] ?: relations["artist"])
?.getJSONObject("attributes")
?.getStringOrNull("name"),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val domain = getDomain()
val mangaId = manga.url.removePrefix("/")
val attrsDeferred = async {
context.httpGet(
"https://api.$domain/manga/${mangaId}?includes[]=artist&includes[]=author&includes[]=cover_art",
).parseJson().getJSONObject("data").getJSONObject("attributes")
}
val feedDeferred = async { loadChapters(mangaId) }
val mangaAttrs = attrsDeferred.await()
val feed = feedDeferred.await()
// 2022-01-02T00:27:11+00:00
val dateFormat = SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss'+00:00'",
Locale.ROOT,
)
manga.copy(
description = mangaAttrs.getJSONObject("description").selectByLocale()
?: manga.description,
chapters = feed.mapChapters { _, jo ->
val id = jo.getString("id")
val attrs = jo.getJSONObject("attributes")
if (!attrs.isNull("externalUrl")) {
return@mapChapters null
}
val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) }
val relations = jo.getJSONArray("relationships").associateByKey("type")
val number = attrs.getIntOrDefault("chapter", 0)
MangaChapter(
id = generateUid(id),
name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
?: "Chapter #$number",
number = number,
url = id,
scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
branch = locale?.getDisplayName(locale)?.toTitleCase(locale),
source = source,
)
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val domain = getDomain()
val chapterJson = context.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false")
.parseJson()
.getJSONObject("chapter")
val pages = chapterJson.getJSONArray("data")
val prefix = "https://uploads.$domain/data/${chapterJson.getString("hash")}/"
val referer = "https://$domain/"
return List(pages.length()) { i ->
val url = prefix + pages.getString(i)
MangaPage(
id = generateUid(url),
url = url,
referer = referer,
preview = null, // TODO prefix + dataSaver.getString(i),
source = source,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val tags = context.httpGet("https://api.${getDomain()}/manga/tag").parseJson()
.getJSONArray("data")
return tags.mapJSONToSet { jo ->
MangaTag(
title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue().toTitleCase(),
key = jo.getString("id"),
source = source,
)
}
}
private fun JSONObject.firstStringValue() = values().next() as String
private fun JSONObject.selectByLocale(): String? {
val preferredLocales = context.getPreferredLocales()
for (locale in preferredLocales) {
getStringOrNull(locale.language)?.let { return it }
getStringOrNull(locale.toLanguageTag())?.let { return it }
}
return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
}
private suspend fun loadChapters(mangaId: String): List<JSONObject> {
val firstPage = loadChapters(mangaId, offset = 0, limit = CHAPTERS_FIRST_PAGE_SIZE)
if (firstPage.size >= firstPage.total) {
return firstPage.data
}
val tail = coroutineScope {
val leftCount = firstPage.total - firstPage.size
val pages = (leftCount / CHAPTERS_MAX_PAGE_SIZE.toFloat()).toIntUp()
val dispatcher = Dispatchers.Default.limitedParallelism(CHAPTERS_PARALLELISM)
List(pages) { page ->
val offset = page * CHAPTERS_MAX_PAGE_SIZE + firstPage.size
async(dispatcher) {
loadChapters(mangaId, offset, CHAPTERS_MAX_PAGE_SIZE)
}
}.awaitAll()
}
val result = ArrayList<JSONObject>(firstPage.total)
result += firstPage.data
tail.flatMapTo(result) { it.data }
return result
}
private suspend fun loadChapters(mangaId: String, offset: Int, limit: Int): Chapters {
val url = buildString {
append("https://api.")
append(getDomain())
append("/manga/")
append(mangaId)
append("/feed")
append("?limit=")
append(limit)
append("&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=")
append(offset)
append('&')
append(CONTENT_RATING)
}
val json = context.httpGet(url).parseJson()
if (json.getString("result") == "ok") {
return Chapters(
data = json.optJSONArray("data")?.toJSONList().orEmpty(),
total = json.getInt("total"),
)
} else {
val error = json.optJSONArray("errors").mapJSON { jo ->
jo.getString("detail")
}.joinToString("\n")
throw ParseException(error, url)
}
}
private class Chapters(
val data: List<JSONObject>,
val total: Int,
) {
val size: Int
get() = data.size
}
}

@ -0,0 +1,152 @@
package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
private const val DEF_BRANCH_NAME = "Основний переклад"
@MangaSourceParser("MANGAINUA", "MANGA/in/UA", "uk")
class MangaInUaParser(override val context: MangaLoaderContext) : PagedMangaParser(
source = MangaSource.MANGAINUA,
pageSize = 24,
searchPageSize = 10,
) {
override val sortOrders: Set<SortOrder>
get() = Collections.singleton(SortOrder.UPDATED)
override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("manga.in.ua", null)
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val url = when {
!query.isNullOrEmpty() -> (
"/index.php?do=search" +
"&subaction=search" +
"&search_start=$page" +
"&full_search=1" +
"&story=$query" +
"&titleonly=3"
).toAbsoluteUrl(getDomain())
tags.isNullOrEmpty() -> "/mangas/page/$page".toAbsoluteUrl(getDomain())
tags.size == 1 -> "${tags.first().key}/page/$page"
tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 genre")
else -> "/mangas/page/$page".toAbsoluteUrl(getDomain())
}
val doc = context.httpGet(url).parseHtml()
val container = doc.body().requireElementById("dle-content")
val items = container.select("div.col-6")
return items.mapNotNull { item ->
val href = item.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null
Manga(
id = generateUid(href),
title = item.selectFirst("h3.card__title")?.text() ?: return@mapNotNull null,
coverUrl = item.selectFirst("header.card__cover")?.selectFirst("img")?.run {
attrAsAbsoluteUrlOrNull("data-src") ?: attrAsAbsoluteUrlOrNull("src")
}.orEmpty(),
altTitle = null,
author = null,
rating = item.selectFirst("div.card__short-rate--num")
?.text()
?.toFloatOrNull()
?.div(10F) ?: RATING_UNKNOWN,
url = href,
isNsfw = item.selectFirst("ul.card__list")?.select("li")?.lastOrNull()?.text() == "18+",
tags = runCatching {
item.selectFirst("div.card__category")?.select("a")?.mapToSet {
MangaTag(
title = it.ownText(),
key = it.attr("href").removeSuffix("/"),
source = source,
)
}
}.getOrNull().orEmpty(),
state = null,
publicUrl = href.toAbsoluteUrl(container.host ?: getDomain()),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
val root = doc.body().requireElementById("dle-content")
val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.US)
val chapterNodes = root.selectFirstOrThrow(".linkstocomics").select(".ltcitems")
var prevChapterName: String? = null
var i = 0
return manga.copy(
description = root.selectFirst("div.item__full-description")?.text(),
largeCoverUrl = root.selectFirst("div.item__full-sidebar--poster")?.selectFirst("img")
?.attrAsAbsoluteUrlOrNull("src"),
chapters = chapterNodes.mapChapters { _, item ->
val href = item?.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
?: return@mapChapters null
val isAlternative = item.styleValueOrNull("background") != null
val name = item.selectFirst("a")?.text().orEmpty()
if (!isAlternative) i++
MangaChapter(
id = generateUid(href),
name = if (isAlternative) {
prevChapterName ?: return@mapChapters null
} else {
prevChapterName = name
name
},
number = i,
url = href,
scanlator = null,
branch = if (isAlternative) {
name.substringAfterLast(':').trim()
} else {
DEF_BRANCH_NAME
},
uploadDate = dateFormat.tryParse(item.selectFirst("div.ltcright")?.text()),
source = source,
)
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
val doc = context.httpGet(fullUrl).parseHtml()
val root = doc.body().requireElementById("comics").selectFirstOrThrow("ul.xfieldimagegallery")
return root.select("li").map { ul ->
val img = ul.selectFirstOrThrow("img")
val url = img.attrAsAbsoluteUrl("data-src")
MangaPage(
id = generateUid(url),
url = url,
preview = null,
referer = fullUrl,
source = source,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val domain = getDomain()
val doc = context.httpGet("https://$domain/mangas").parseHtml()
val root = doc.body().requireElementById("menu_1").selectFirstOrThrow("div.menu__wrapper")
return root.select("li").mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
MangaTag(
title = a.ownText(),
key = a.attr("href").removeSuffix("/"),
source = source,
)
}
}
}

@ -0,0 +1,187 @@
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.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@Deprecated("Permanently shutdown")
@MangaSourceParser("MANGAOWL", "MangaOwl", "en")
internal class MangaOwlParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.MANGAOWL) {
override val configKeyDomain = ConfigKey.Domain("mangaowls.com", null)
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY,
SortOrder.NEWEST,
SortOrder.UPDATED,
)
private val regexNsfw = Regex("(yaoi)|(yuri)|(smut)|(mature)|(adult)", RegexOption.IGNORE_CASE)
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val page = (offset / 36f).toIntUp().inc()
val link = buildString {
append("https://")
append(getDomain())
when {
!query.isNullOrEmpty() -> {
append("/search/$page?search=")
append(query.urlEncoded())
}
!tags.isNullOrEmpty() -> {
for (tag in tags) {
append(tag.key)
}
append("/$page?type=${getAlternativeSortKey(sortOrder)}")
}
else -> {
append("/${getSortKey(sortOrder)}/$page")
}
}
}
val doc = context.httpGet(link).parseHtml()
val slides = doc.body().selectOrThrow("ul.slides")
val items = slides.select("div.col-md-2")
return items.mapNotNull { item ->
val href = item.selectFirst("h6 a")?.attrAsRelativeUrlOrNull("href") ?: return@mapNotNull null
Manga(
id = generateUid(href),
title = item.selectFirst("h6 a")?.text() ?: return@mapNotNull null,
coverUrl = item.select("div.img-responsive").attr("abs:data-background-image"),
altTitle = null,
author = null,
rating = runCatching {
item.selectFirst("div.block-stars")
?.text()
?.toFloatOrNull()
?.div(10f)
}.getOrNull() ?: RATING_UNKNOWN,
url = href,
isNsfw = false,
tags = emptySet(),
state = null,
publicUrl = href.toAbsoluteUrl(getDomain()),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.publicUrl).parseHtml()
val info = doc.body().selectFirstOrThrow("div.single_detail")
val table = doc.body().selectFirstOrThrow("div.single-grid-right")
val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.US)
val trRegex = "window\\['tr'] = '([^']*)';".toRegex(RegexOption.IGNORE_CASE)
val trElement = doc.getElementsByTag("script").find { trRegex.find(it.data()) != null }
?: doc.parseFailed("Oops, tr not found")
val tr = trRegex.find(trElement.data())!!.groups[1]!!.value
val s = context.encodeBase64(getDomain().toByteArray())
var isNsfw = manga.isNsfw
val parsedTags = info.select("div.col-xs-12.col-md-8.single-right-grid-right > p > a[href*=genres]")
.mapNotNullToSet {
val a = it.selectFirst("a") ?: return@mapNotNullToSet null
val name = a.text()
if (!isNsfw && isNsfwGenre(name)) {
isNsfw = true
}
MangaTag(
title = name.toTitleCase(),
key = a.attr("href"),
source = source,
)
}
return manga.copy(
description = info.selectFirst(".description")?.html(),
largeCoverUrl = info.select("img").first()?.let { img ->
if (img.hasAttr("data-src")) img.attr("abs:data-src") else img.attr("abs:src")
},
isNsfw = isNsfw,
author = info.selectFirst("p.fexi_header_para a.author_link")?.text(),
state = parseStatus(info.select("p.fexi_header_para:contains(status)").first()?.ownText()),
tags = manga.tags + parsedTags,
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list")
.asReversed().mapChapters { i, li ->
val a = li.select("a")
val href = a.attr("data-href").ifEmpty {
li.parseFailed("Link is missing")
}
MangaChapter(
id = generateUid(href),
name = a.select("label").text(),
number = i + 1,
url = "$href?tr=$tr&s=$s",
scanlator = null,
branch = null,
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
source = MangaSource.MANGAOWL,
)
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
val doc = context.httpGet(fullUrl).parseHtml()
val root = doc.body().selectOrThrow("div.item img.owl-lazy")
return root.map { div ->
val url = div?.attrAsRelativeUrlOrNull("data-src") ?: doc.parseFailed("Page image not found")
MangaPage(
id = generateUid(url),
url = url,
preview = null,
referer = url,
source = MangaSource.MANGAOWL,
)
}
}
private fun parseStatus(status: String?) = when {
status == null -> null
status.contains("Ongoing") -> MangaState.ONGOING
status.contains("Completed") -> MangaState.FINISHED
else -> null
}
override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}/").parseHtml()
val root = doc.body().select("ul.dropdown-menu.multi-column.columns-3").select("li")
return root.mapToSet { p ->
val a = p.selectFirstOrThrow("a")
MangaTag(
title = a.text().toTitleCase(),
key = a.attr("href"),
source = source,
)
}
}
private fun getSortKey(sortOrder: SortOrder) =
when (sortOrder) {
SortOrder.POPULARITY -> "popular"
SortOrder.NEWEST -> "new_release"
SortOrder.UPDATED -> "lastest"
else -> "lastest"
}
private fun getAlternativeSortKey(sortOrder: SortOrder) =
when (sortOrder) {
SortOrder.POPULARITY -> "0"
SortOrder.NEWEST -> "2"
SortOrder.UPDATED -> "3"
else -> "3"
}
private fun isNsfwGenre(name: String): Boolean = regexNsfw.containsMatchIn(name)
}

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

Loading…
Cancel
Save