Compare commits

..

No commits in common. 'fe5534b006322188080f6a8fa1d3f04bddb3b6c1' and '071f4f091107fd9f66b635fc08c2a74e3337a397' have entirely different histories.

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

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

@ -1,31 +1,30 @@
name: ⭐ Feature request name: ⭐ Feature request
description: Suggest a feature to improve a source description: Suggest a feature to improve a source
labels: [ feature request ] labels: [feature request]
body: body:
- type: textarea - type: textarea
id: feature-description id: feature-description
attributes: attributes:
label: Describe your suggested feature label: Describe your suggested feature
description: How can an existing source be improved? description: How can an existing source be improved?
placeholder: | placeholder: |
Example: Example:
"It should work like this..." "It should work like this..."
Please use English language validations:
validations: required: true
required: true
- type: textarea - type: textarea
id: other-details id: other-details
attributes: attributes:
label: Other details label: Other details
placeholder: | placeholder: |
Additional details and attachments. Additional details and attachments.
- type: checkboxes - type: checkboxes
id: acknowledgements id: acknowledgements
attributes: attributes:
label: Acknowledgements label: Acknowledgements
options: options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true required: true

@ -1,31 +1,33 @@
name: 🗑 Source removal request name: 🗑 Source removal request
description: Scanlators can request their site to be removed description: Scanlators can request their site to be removed
labels: [ source removal ] labels: [source removal]
body: body:
- type: input - type: input
id: link id: link
attributes: attributes:
label: Source link label: Source link
placeholder: | placeholder: |
Example: "https://example.org" Example: "https://example.org"
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: other-details id: other-details
attributes: attributes:
label: Other details (reason for removal, etc) label: Other details
placeholder: | placeholder: |
Additional details and attachments. Additional details and attachments.
- type: checkboxes - type: checkboxes
id: requirements id: requirements
attributes: attributes:
label: Requirements label: Requirements
description: Your request will be denied if you don't meet these requirements. description: Your request will be denied if you don't meet these requirements.
options: 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 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) - 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 required: true
- label: Site is not infested with user-hostile features (e.g., invasive or malicious ads)
required: true

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

@ -1,27 +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@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: '17'
distribution: 'temurin'
- name: Set up Gradle 📦
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
with:
cache-read-only: true
- name: Compile parsers 🚀
run: ./gradlew compileKotlin

@ -1,4 +1,4 @@
name: Parsers test for PRs name: Parsers test
on: on:
workflow_dispatch: workflow_dispatch:
@ -13,19 +13,10 @@ jobs:
build-and-test: build-and-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository 🌏 - uses: actions/checkout@v3
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/setup-java@v3
with:
- name: Set up enviroment 🔧 java-version: '11'
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: '17'
distribution: 'temurin' distribution: 'temurin'
cache: 'gradle'
- name: Set up Gradle 📦 - run: ./gradlew assemble
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
with:
cache-read-only: true
- name: Compile parsers 🚀
run: ./gradlew compileKotlin

13
.gitignore vendored

@ -17,7 +17,6 @@
.idea/**/dynamic.xml .idea/**/dynamic.xml
.idea/**/uiDesigner.xml .idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml .idea/**/dbnavigator.xml
.idea/**/Project_Default.xml
# Gradle # Gradle
.idea/**/gradle.xml .idea/**/gradle.xml
@ -27,8 +26,6 @@
# When using Gradle or Maven with auto-import, you should exclude module files, # 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 # since they will be recreated, and may cause churn. Uncomment if using
# auto-import. # auto-import.
.idea/deviceManager.xml
.idea/.name
.idea/artifacts .idea/artifacts
.idea/compiler.xml .idea/compiler.xml
.idea/jarRepositories.xml .idea/jarRepositories.xml
@ -74,21 +71,11 @@ fabric.properties
.gradle/ .gradle/
build/ build/
bin/
.idea/**/misc.xml .idea/**/misc.xml
.idea/**/vcs.xml .idea/**/vcs.xml
.idea/**/ktlint.xml .idea/**/ktlint.xml
.idea/codeStyles/ .idea/codeStyles/
.idea/kotlinc.xml
src/test/resources/cookies.txt src/test/resources/cookies.txt
local.properties local.properties
.kotlin/
!/.idea/kotlin-statistics.xml
.idea/**/discord.xml
.idea/**/migrations.xml
.idea/**/runConfigurations.xml
.idea/**/AndroidProjectSystem.xml
.idea/caches/deviceStreaming.xml

5
.idea/.gitignore vendored

@ -1,8 +1,3 @@
# Default ignored files # Default ignored files
/shelf/ /shelf/
/workspace.xml /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.9.22" />
</component>
</project>

@ -1,10 +1,10 @@
# Contributing # 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 ## 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/) - Basic [Android development](https://developer.android.com/)
- [Kotlin](https://kotlinlang.org/) - [Kotlin](https://kotlinlang.org/)
@ -16,24 +16,24 @@ Before you start, please note that the ability to use the following technologies
- [IntelliJ IDEA](https://www.jetbrains.com/idea/) (Community edition is enough) - [IntelliJ IDEA](https://www.jetbrains.com/idea/) (Community edition is enough)
- Android device (or emulator) - 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 Kotatsu parsers is not a part of Android application, but you can easily develop and test it directly inside an Android
Android application project and relocate it to the library project when done. application project and relocate it to the library project when done.
### Before you start ### Before you start
First, take a look at the `kotatsu-parsers` project structure. Each parser is a single class that First, take a look at `kotatsu-parsers` project structure. Each parser is a single class that
extends the `MangaParser` class and has a `MangaSourceParser` annotation. extends `MangaParser` class and have a `MangaSourceParser` annotation.
Also, pay attention to extensions in the `util` package. For example, extensions from the `Jsoup` file 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 should be used instead of existing JSoup functions because they have better nullability support
and improved error messages. and improved error messages.
## Writing your parser ## Writing your parser
So, you want to create a parser, that will provide access to manga from a website. 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 If it does not contain any documentation about
API, [explore network requests](https://firefox-source-docs.mozilla.org/devtools-user/): 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/ru/DesuMeParser.kt)
of Json API usage. of Json API usage.
@ -42,48 +42,43 @@ some websites use AJAX.
- [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/en/MangaTownParser.kt)
of pure HTML parsing. 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 If website is based on some engine it is rationally to use common base class for this one (for example, Madara wordress
Wordpress theme and the `MadaraParser` class) theme
and the `MadaraParser` class)
### Parser class skeleton ### Parser class skeleton
The parser class must have exactly one primary constructor parameter of type `MangaLoaderContext` and have an 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. `MangaSourceParser` annotation that provides internal name, title and language of a manga source.
All members of the `MangaParser` class are documented. Pay attention to some peculiarities: All functions in `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 - Never hardcode domain. Specify default domain in `configKeyDomain` field and obtain an actual one using `getDomain()`.
`domain`. - All ids must be unique and domain-independent. Use `generateUid` functions with relative url or some internal id which
- All IDs must be unique and domain-independent. Use `generateUid` functions with a relative URL or some internal id is unique across the manga source.
that is unique across the manga source. - `sortOrders` set should not be empty. If your source is not support sorting, specify one most relevance value.
- The `availableSortOrders` set should not be empty. If your source does not support sorting, specify one most relevant - If you cannot obtain direct links to pages images inside `getPages` method, it is ok to use an intermediate url
value. as `Page.url` and fetch a direct link at `getPageUrl` function.
- If you cannot obtain direct links to page images inside the `getPages` method, it is ok to use an intermediate URL - You can use _asserts_ to check some optional fields. For example. `Manga.author` field is not required, but if your
as `Page.url` and fetch a direct link in the `getPageUrl` function. source provide such information, add `assert(it != null)`. This will not have any effect on production but help to
- You can use _asserts_ to check some optional fields. For example, the `Manga.author` field is not required, but if find issues during unit testing.
your source provides this information, add `assert(it != null)`. This will not have any effect on production but help - If your source website (or it's api) uses pages for pagination instead of offset you should extend `PagedMangaParser`
to find issues during unit testing.
- Your parser may also implement the `Interceptor` interface for additional manipulation of all network requests and
responses, including image loading.
- If your source website (or its API) uses pages for pagination instead of offset you should extend `PagedMangaParser`
instead of `MangaParser`. instead of `MangaParser`.
- If your source website (or its API) does not provide pagination (has only one page of content) you should extend - Your parser may also implement the `Interceptor` interface for additional manipulation of all network requests and/or
`SinglePageMangaParser` instead of `MangaParser` or `PagedMangaParser`. responses, including image loading.
![parser_classes.png](docs/parser_classes.png)
## Development process ## Development process
During the development, it is recommended (but not necessary) to write it directly 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` in the Kotatsu android application project. You can use `core.parser.DummyParser` class as a sandbox. `Dummy` manga
manga source is available in the debug Kotatsu build. 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 Once parser is ready you can relocate your code into `kotatsu-parsers` library project in a `site` package and create a
create a Pull Request. Pull Request.
### Testing ### 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 - Temporary modify the `MangaSources` annotation class: specify your parser(s) name(s) and change mode
to `EnumSource.Mode.INCLUDE` to `EnumSource.Mode.INCLUDE`
@ -92,5 +87,5 @@ It is recommended that unit tests be run before submitting a PR.
## Help ## Help
If you need help or have some questions, ask a community in our [Telegram chat](https://t.me/kotatsuapp) If you need a help or have some questions, ask a community in our [Telegram chat](https://t.me/kotatsuapp)
or [Discord server](https://discord.gg/NNJ5RgVBC5). or [Discord server](https://discord.gg/NNJ5RgVBC5).

@ -1,9 +1,8 @@
# Kotatsu parsers # Kotatsu parsers
This library provides a collection of manga parsers for convenient access manga available on the web. It can be used in This library provides manga sources.
JVM and Android applications.
![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) [![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)
## Usage ## Usage
@ -47,22 +46,16 @@ JVM and Android applications.
3. Usage in code 3. Usage in code
```kotlin ```kotlin
val parser = mangaLoaderContext.newParserInstance(MangaParserSource.MANGADEX) val parser = mangaLoaderContext.newParserInstance(MangaSource.MANGADEX)
``` ```
`mangaLoaderContext` is an implementation of the `MangaLoaderContext` class. `mangaLoaderContext` is an implementation of the `MangaLoaderContext` class.
See examples See examples
of [Android](https://github.com/KotatsuApp/Kotatsu/blob/devel/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt) 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) and [Non-Android](https://github.com/KotatsuApp/kotatsu-dl/blob/master/src/jvmMain/kotlin/org/koitharu/kotatsu_dl/logic/MangaLoaderContextImpl.kt)
implementation. implementation.
## Projects that use the library Note that the `MangaSource.LOCAL` and `MangaSource.DUMMY` parsers cannot be instantiated.
- [Kotatsu](https://github.com/KotatsuApp/Kotatsu)
- [Doki](https://github.com/DokiTeam/Doki)
- [kotatsu-dl](https://github.com/KotatsuApp/kotatsu-dl)
- [Shirizu (WIP)](https://github.com/ztimms73/shirizu)
- [OtakuWorld](https://github.com/jakepurple13/OtakuWorld)
## Contribution ## Contribution

@ -0,0 +1,72 @@
import tasks.ReportGenerateTask
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm' version '1.9.22'
id 'com.google.devtools.ksp' version '1.9.22-1.0.17'
id 'maven-publish'
}
group = 'org.koitharu'
version = '1.0'
test {
useJUnitPlatform()
}
compileKotlin {
kotlinOptions {
freeCompilerArgs += [
'-opt-in=kotlin.RequiresOptIn',
'-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi',
]
}
}
compileTestKotlin {
kotlinOptions {
freeCompilerArgs += [
'-opt-in=kotlin.RequiresOptIn',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi',
]
}
}
kotlin {
jvmToolchain(8)
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.7.3'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okio:okio:3.7.0'
api 'org.jsoup:jsoup:1.17.2'
implementation 'org.json:json:20231013'
implementation 'androidx.collection:collection:1.4.0'
ksp project(':kotatsu-parsers-ksp')
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.1'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.1'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
testImplementation 'io.webfolder:quickjs:1.1.0'
}
tasks.register('generateTestsReport', ReportGenerateTask)

@ -1,63 +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)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.quickjs)
}
tasks.register<ReportGenerateTask>("generateTestsReport")

@ -0,0 +1,18 @@
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.9.22'
}
repositories {
mavenCentral()
}
kotlin {
jvmToolchain(8)
}
dependencies {
implementation gradleApi()
implementation 'org.simpleframework:simple-xml:2.7.1'
implementation 'com.soywiz.korlibs.korte:korte-jvm:4.0.10'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
}

@ -1,18 +0,0 @@
plugins {
kotlin("jvm") version "2.2.10"
}
repositories {
mavenCentral()
}
kotlin {
jvmToolchain(8)
}
dependencies {
implementation(gradleApi())
implementation("org.simpleframework:simple-xml:2.7.1")
implementation("com.soywiz.korlibs.korte:korte-jvm:4.0.10")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}

@ -1,6 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

@ -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,9 +1,2 @@
# Following this blog:
# https://proandroiddev.com/how-we-reduced-our-gradle-build-times-by-over-80-51f2b6d6b05b
kotlin.code.style=official kotlin.code.style=official
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=4096m -XX:+UseParallelGC org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=4096m
org.gradle.vfs.watch=true
org.gradle.configureondemand=true
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.unsafe.configuration-cache=true

@ -1,29 +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"
[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-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" }

@ -1,7 +1,5 @@
#Wed Aug 27 01:56:37 ICT 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

@ -0,0 +1,11 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
}
kotlin {
jvmToolchain(8)
}
dependencies {
implementation 'com.google.devtools.ksp:symbol-processing-api:1.9.22-1.0.17'
}

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

@ -7,180 +7,165 @@ import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSVisitorVoid import com.google.devtools.ksp.symbol.KSVisitorVoid
import com.google.devtools.ksp.validate import com.google.devtools.ksp.validate
import java.io.File
import java.io.Writer import java.io.Writer
import java.util.* import java.util.*
class ParserProcessor( class ParserProcessor(
private val codeGenerator: CodeGenerator, private val codeGenerator: CodeGenerator,
private val logger: KSPLogger, private val logger: KSPLogger,
private val options: Map<String, String>, private val options: Map<String, String>,
) : SymbolProcessor { ) : 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 ret = symbols.filterNot { it.validate() }.toList()
if (!symbols.iterator().hasNext()) {
return ret
}
val dependencies = Dependencies.ALL_FILES
val factoryFile =
try {
codeGenerator.createNewFile(
dependencies = dependencies,
packageName = "org.koitharu.kotatsu.parsers",
fileName = "MangaParserFactory",
)
} catch (e: FileAlreadyExistsException) {
logger.warn(e.toString(), null)
null
}
val sourcesFile =
try {
codeGenerator.createNewFile(
dependencies = dependencies,
packageName = "org.koitharu.kotatsu.parsers.model",
fileName = "MangaSource",
)
} catch (e: FileAlreadyExistsException) {
logger.warn(e.toString(), null)
null
}
val totalCount = sourcesFile?.writer().use { sourcesWriter ->
factoryFile?.writer().use { factoryWriter ->
writeContent(sourcesWriter, factoryWriter, symbols)
}
}
writeSummary(totalCount)
return ret
}
private fun writeContent(
sourcesWriter: Writer?,
factoryWriter: Writer?,
symbols: Sequence<KSAnnotated>,
): Int {
if (sourcesWriter == null && factoryWriter == null) {
return 0
}
factoryWriter?.write(
"""
package org.koitharu.kotatsu.parsers
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.core.MangaParserWrapper
internal fun MangaParserSource.newParser(context: MangaLoaderContext): MangaParser = when (this) {
""".trimIndent(), private val availableLocales = Locale.getAvailableLocales().toSet()
) private val sourceNamePattern = Regex("[A-Z_][A-Z0-9_]{3,}")
sourcesWriter?.write(
""" override fun process(resolver: Resolver): List<KSAnnotated> {
package org.koitharu.kotatsu.parsers.model val symbols = resolver
.getSymbolsWithAnnotation("org.koitharu.kotatsu.parsers.MangaSourceParser")
public enum class MangaParserSource( val ret = symbols.filterNot { it.validate() }.toList()
public val title: String, if (!symbols.iterator().hasNext()) {
public val locale: String, return ret
public val contentType: ContentType, }
public val isBroken: Boolean, val dependencies = Dependencies.ALL_FILES
): MangaSource { val factoryFile = try {
codeGenerator.createNewFile(
dependencies = dependencies,
packageName = "org.koitharu.kotatsu.parsers",
fileName = "MangaParserFactory",
)
} catch (e: FileAlreadyExistsException) {
logger.warn(e.toString(), null)
null
}
val sourcesFile = try {
codeGenerator.createNewFile(
dependencies = dependencies,
packageName = "org.koitharu.kotatsu.parsers.model",
fileName = "MangaSource",
)
} catch (e: FileAlreadyExistsException) {
logger.warn(e.toString(), null)
null
}
sourcesFile?.writer().use { sourcesWriter ->
factoryFile?.writer().use { factoryWriter ->
writeContent(sourcesWriter, factoryWriter, symbols)
}
}
return ret
}
private fun writeContent(
sourcesWriter: Writer?,
factoryWriter: Writer?,
symbols: Sequence<KSAnnotated>,
) {
if (sourcesWriter == null && factoryWriter == null) {
return
}
factoryWriter?.write(
"""
package org.koitharu.kotatsu.parsers
import org.koitharu.kotatsu.parsers.model.MangaSource
@Suppress("DEPRECATION")
@InternalParsersApi
@Deprecated("", replaceWith = ReplaceWith("context.newParserInstance(this)"))
fun MangaSource.newParser(context: MangaLoaderContext): MangaParser = when (this) {
""".trimIndent(), """.trimIndent(),
) )
//language=kotlin
sourcesWriter?.write(
"""
package org.koitharu.kotatsu.parsers.model
enum class MangaSource(
val title: String,
val locale: String?,
val contentType: ContentType,
) {
LOCAL("Local", null, ContentType.OTHER),
val visitor = ParserVisitor(sourcesWriter, factoryWriter) """.trimIndent(),
val totalCount = symbols )
.filter { it is KSClassDeclaration && it.validate() }
.onEach { it.accept(visitor, Unit) } val visitor = ParserVisitor(sourcesWriter, factoryWriter)
.count() symbols
.filter { it is KSClassDeclaration && it.validate() }
factoryWriter?.write( .forEach { it.accept(visitor, Unit) }
"""
}.let { factoryWriter?.write(
"""
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) { require(it.source == this) {
"Cannot instantiate manga parser: ${'$'}name mapped to ${'$'}{it.source}" "Cannot instantiate manga parser: ${'$'}name mapped to ${'$'}{it.source}"
} }
MangaParserWrapper(it)
} }
""".trimIndent(), """.trimIndent(),
) )
sourcesWriter?.write( sourcesWriter?.write(
""" """
DUMMY("Dummy", null, ContentType.OTHER),
; ;
} }
""".trimIndent(), """.trimIndent(),
) )
return totalCount }
}
private inner class ParserVisitor(
private fun writeSummary(totalCount: Int) { private val sourcesWriter: Writer?,
val file = File(options["summaryOutputDir"] ?: return, "summary.yaml") private val factoryWriter: Writer?,
file.writeText("total: $totalCount") ) : KSVisitorVoid() {
}
private val titles = HashMap<String, String>()
private inner class ParserVisitor(
private val sourcesWriter: Writer?, override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
private val factoryWriter: Writer?, if (classDeclaration.classKind != ClassKind.CLASS || classDeclaration.isAbstract()) {
) : KSVisitorVoid() { logger.error("Only non-abstract can be annotated with @MangaSourceParser", classDeclaration)
private val titles = HashMap<String, String>() }
val annotation = classDeclaration.annotations.single { it.shortName.asString() == "MangaSourceParser" }
override fun visitClassDeclaration( val deprecation = classDeclaration.annotations.singleOrNull { it.shortName.asString() == "Deprecated" }
classDeclaration: KSClassDeclaration, val name = annotation.arguments.single { it.name?.asString() == "name" }.value as String
data: Unit, val title = annotation.arguments.single { it.name?.asString() == "title" }.value as String
) { val locale = annotation.arguments.single { it.name?.asString() == "locale" }.value as String
if (classDeclaration.classKind != ClassKind.CLASS || classDeclaration.isAbstract()) { val type = annotation.arguments.single { it.name?.asString() == "type" }.value
logger.error("Only non-abstract can be annotated with @MangaSourceParser", classDeclaration) val localeString = if (locale.isEmpty()) "null" else "\"$locale\""
} val localeObj = if (locale.isEmpty()) null else Locale(locale)
val annotation = classDeclaration.annotations.single { it.shortName.asString() == "MangaSourceParser" } val localeTitle = localeObj?.getDisplayLanguage(localeObj)
val deprecation = classDeclaration.annotations.singleOrNull { it.shortName.asString() == "Deprecated" } if (localeObj != null && localeObj !in availableLocales) {
val isBroken = classDeclaration.annotations.any { it.shortName.asString() == "Broken" } logger.error("Manga source $name has wrong locale: $localeTitle")
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 if (!sourceNamePattern.matches(name)) {
val type = annotation.arguments.single { it.name?.asString() == "type" }.value logger.error("Manga source name must be uppercase: $name")
val localeString = "\"$locale\"" }
val localeObj = if (locale.isEmpty()) null else Locale(locale)
val localeTitle = localeObj?.getDisplayLanguage(localeObj) val constructor = classDeclaration.primaryConstructor
if (localeObj != null && localeObj !in availableLocales) { if (constructor == null || constructor.parameters.count { !it.hasDefault } != 1) {
logger.error("Manga source $name has wrong locale: $localeTitle") logger.error(
} "Class with @MangaSourceParser must have a primary constructor with one parameter",
classDeclaration,
if (!sourceNamePattern.matches(name)) { )
logger.error("Manga source name must be uppercase: $name") }
} val className = checkNotNull(classDeclaration.qualifiedName?.asString()) { "Class name is null" }
val constructor = classDeclaration.primaryConstructor val prevTitleClass = titles.put(title, className)
if (constructor == null || constructor.parameters.count { !it.hasDefault } != 1) { if (prevTitleClass != null) {
logger.error( logger.warn("Source title duplication: \"$title\" is assigned to both $prevTitleClass and $className")
"Class with @MangaSourceParser must have a primary constructor with one parameter", }
classDeclaration,
) factoryWriter?.write("\tMangaSource.$name -> $className(context)\n")
} val deprecationString = if (deprecation != null) {
val className = checkNotNull(classDeclaration.qualifiedName?.asString()) { "Class name is null" } val reason = deprecation.arguments
.find { it.name?.asString() == "message" }?.value?.toString() ?: "Unknown reason"
val prevTitleClass = titles.put(title, className) "@Deprecated(\"$reason\") "
if (prevTitleClass != null) { } else ""
logger.warn("Source title duplication: \"$title\" is assigned to both $prevTitleClass and $className") val localeComment = localeTitle?.toTitleCase(localeObj)?.let { " /* $it */" }.orEmpty()
} sourcesWriter?.write("\t$deprecationString$name(\"$title\", $localeString$localeComment, ContentType.$type),\n")
}
factoryWriter?.write("\tMangaParserSource.$name -> $className(context)\n") }
val deprecationString =
if (deprecation != null) {
val reason =
deprecation.arguments
.find { it.name?.asString() == "message" }
?.value
?.toString() ?: "Unknown reason"
"@Deprecated(\"$reason\") "
} else {
""
}
val localeComment = localeTitle?.toTitleCase(localeObj)?.let { " /* $it */" }.orEmpty()
sourcesWriter?.write(
"\t$deprecationString$name(\"$title\", $localeString$localeComment, $type, $isBroken),\n",
)
}
}
} }

@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
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 +1,14 @@
package org.koitharu.kotatsu.parsers package org.koitharu.kotatsu.parsers
public object ErrorMessages { object ErrorMessages {
public const val FILTER_MULTIPLE_STATES_NOT_SUPPORTED: String = "Multiple states are not supported by this source" const val FILTER_MULTIPLE_STATES_NOT_SUPPORTED = "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" const val FILTER_MULTIPLE_GENRES_NOT_SUPPORTED = "Multiple genres are not supported by this source"
public const val FILTER_MULTIPLE_CONTENT_RATING_NOT_SUPPORTED: String = const val FILTER_MULTIPLE_CONTENT_RATING_NOT_SUPPORTED =
"Multiple Content ratings are not supported by this source" "Multiple Content Rating are not supported by this source"
public const val FILTER_MULTIPLE_CONTENT_TYPES_NOT_SUPPORTED: String = const val FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED =
"Multiple Content types are not supported by this source" "Filtering by both genres and locale is not supported by this source"
public const val FILTER_MULTIPLE_DEMOGRAPHICS_NOT_SUPPORTED: String = const val FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED =
"Multiple Demographics are not supported by this source" "Filtering by both genres and states is not supported by this source"
public const val FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED: String = const val SEARCH_NOT_SUPPORTED = "Search is not supported by this source"
"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") @SinceKotlin("1.3")
@RequiresOptIn @RequiresOptIn
@MustBeDocumented @MustBeDocumented
public annotation class InternalParsersApi annotation class InternalParsersApi

@ -1,78 +1,32 @@
package org.koitharu.kotatsu.parsers package org.koitharu.kotatsu.parsers
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Response
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.LinkResolver
import java.util.* import java.util.*
public abstract class MangaLoaderContext { abstract class MangaLoaderContext {
public abstract val httpClient: OkHttpClient abstract val httpClient: OkHttpClient
public abstract val cookieJar: CookieJar abstract val cookieJar: CookieJar
public fun newParserInstance(source: MangaParserSource): MangaParser = source.newParser(this) @Suppress("DEPRECATION")
fun newParserInstance(source: MangaSource): MangaParser = source.newParser(this)
public fun newLinkResolver(link: HttpUrl): LinkResolver = LinkResolver(this, link) open fun encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
public fun newLinkResolver(link: String): LinkResolver = newLinkResolver(link.toHttpUrl()) open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data)
public open fun encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data) open fun getPreferredLocales(): List<Locale> = listOf(Locale.getDefault())
public open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data)
public open fun getPreferredLocales(): List<Locale> = listOf(Locale.getDefault())
/** /**
* Execute JavaScript code and return result * Execute JavaScript code and return result
* @param script JavaScript source code * @param script JavaScript source code
* @return execution result as string, may be null * @return execution result as string, may be null
*/ */
@Deprecated("Provide a base url") abstract suspend fun evaluateJs(script: String): String?
public abstract suspend fun evaluateJs(script: String): String?
/**
* 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
*/
public abstract suspend fun evaluateJs(baseUrl: String, script: String): String?
/**
* Open [url] in browser for some external action (e.g. captcha solving or non cookie-based authorization)
*/
public open fun requestBrowserAction(parser: MangaParser, url: String): Nothing {
throw UnsupportedOperationException("Browser is not available")
}
public abstract fun getConfig(source: MangaSource): MangaSourceConfig
public abstract fun getDefaultUserAgent(): String abstract fun getConfig(source: MangaSource): MangaSourceConfig
/**
* 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
/**
* create a new empty Bitmap with given dimensions
*/
public abstract fun createBitmap(
width: Int,
height: Int,
): Bitmap
} }

@ -1,88 +1,253 @@
package org.koitharu.kotatsu.parsers package org.koitharu.kotatsu.parsers
import androidx.annotation.CallSuper
import androidx.annotation.VisibleForTesting
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Interceptor
import org.koitharu.kotatsu.parsers.config.ConfigKey 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.*
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.LinkResolver import org.koitharu.kotatsu.parsers.network.WebClient
import org.koitharu.kotatsu.parsers.util.convertToMangaSearchQuery import org.koitharu.kotatsu.parsers.util.FaviconParser
import org.koitharu.kotatsu.parsers.util.toMangaListFilterCapabilities import org.koitharu.kotatsu.parsers.util.RelatedMangaFinder
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import java.util.* import java.util.*
public interface MangaParser : Interceptor { abstract class MangaParser @InternalParsersApi constructor(
@property:InternalParsersApi val context: MangaLoaderContext,
public val source: MangaParserSource val source: MangaSource,
) {
/** /**
* Supported [SortOrder] variants. Must not be empty. * Supported [SortOrder] variants. Must not be empty.
* *
* For better performance use [EnumSet] for more than one item. * For better performance use [EnumSet] for more than one item.
*/ */
public val availableSortOrders: Set<SortOrder> abstract val availableSortOrders: Set<SortOrder>
/**
* Supported [MangaState] variants for filtering. May be empty.
*
* For better performance use [EnumSet] for more than one item.
*/
open val availableStates: Set<MangaState>
get() = emptySet()
open val availableContentRating: Set<ContentRating>
get() = emptySet()
/**
* Whether parser supports filtering by more than one tag
*/
open val isMultipleTagsSupported: Boolean = true
/**
* Whether parser supports tagsExclude field in filter
*/
open val isTagsExclusionSupported: Boolean = false
/**
* Whether parser supports searching by string query using [MangaListFilter.Search]
*/
open val isSearchSupported: Boolean = true
@Deprecated("Too complex. Use filterCapabilities instead") @Deprecated(
public val searchQueryCapabilities: MangaSearchQueryCapabilities message = "Use availableSortOrders instead",
replaceWith = ReplaceWith("availableSortOrders"),
)
open val sortOrders: Set<SortOrder>
get() = availableSortOrders
public val filterCapabilities: MangaListFilterCapabilities val config by lazy { context.getConfig(source) }
public val config: MangaSourceConfig open val sourceLocale: Locale
get() = source.locale?.let { Locale(it) } ?: Locale.ROOT
public val authorizationProvider: MangaParserAuthProvider? val isNsfwSource = source.contentType == ContentType.HENTAI
get() = this as? MangaParserAuthProvider
/** /**
* Provide default domain and available alternatives, if any. * Provide default domain and available alternatives, if any.
* *
* Never hardcode domain in requests, use [domain] instead. * Never hardcode domain in requests, use [domain] instead.
*/ */
public val configKeyDomain: ConfigKey.Domain @InternalParsersApi
abstract val configKeyDomain: ConfigKey.Domain
open val headers: Headers = Headers.Builder()
.add("User-Agent", UserAgents.CHROME_MOBILE)
.build()
/**
* Used as fallback if value of `sortOrder` passed to [getList] is null
*/
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
open val defaultSortOrder: SortOrder
get() {
val supported = availableSortOrders
return SortOrder.entries.first { it in supported }
}
@JvmField
protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source)
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 [getAvailableTags] and [Manga.tags]. May be null or empty
* @param sortOrder one of [availableSortOrders] or null for default value
*/
@JvmSynthetic
@InternalParsersApi
@Deprecated(
"Use getList with filter instead",
replaceWith = ReplaceWith("getList(offset, filter)"),
)
open suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
tagsExclude: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> = throw NotImplementedError("Please implement getList(offset, filter) instead")
@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
*/
@Deprecated(
"Use getList with filter instead",
ReplaceWith(
"getList(offset, MangaListFilter.Search(query))",
"org.koitharu.kotatsu.parsers.model.MangaListFilter",
),
)
open suspend fun getList(offset: Int, query: String): List<Manga> {
return getList(offset, MangaListFilter.Search(query))
}
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 [getAvailableTags] and [Manga.tags]. May be null or empty
* @param sortOrder one of [availableSortOrders] or null for default value
*/
@Deprecated(
"Use getList with filter instead",
ReplaceWith(
"getList(offset, MangaListFilter.Advanced(sortOrder, tags, null, emptySet()))",
"org.koitharu.kotatsu.parsers.model.MangaListFilter",
),
)
open suspend fun getList(
offset: Int,
tags: Set<MangaTag>?,
tagsExclude: Set<MangaTag>?,
sortOrder: SortOrder?,
): List<Manga> {
return getList(
offset,
MangaListFilter.Advanced(
sortOrder = sortOrder ?: defaultSortOrder,
tags = tags.orEmpty(),
tagsExclude = tagsExclude.orEmpty(),
locale = null,
states = emptySet(),
contentRating = emptySet(),
),
)
}
@Suppress("DEPRECATION")
open suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
return when (filter) {
is MangaListFilter.Advanced -> getList(
offset = offset,
query = null,
tags = filter.tags,
tagsExclude = filter.tagsExclude,
sortOrder = filter.sortOrder,
)
is MangaListFilter.Search -> getList(
offset = offset,
query = filter.query,
tags = null,
tagsExclude = null,
sortOrder = defaultSortOrder,
)
null -> getList(
offset = offset,
query = null,
tags = null,
tagsExclude = null,
sortOrder = defaultSortOrder,
)
}
}
/** /**
* Parse details for [Manga]: chapters list, description, large cover, etc. * Parse details for [Manga]: chapters list, description, large cover, etc.
* Must return the same manga, may change any fields excepts id, url and source * Must return the same manga, may change any fields excepts id, url and source
* @see Manga.copy * @see Manga.copy
*/ */
public suspend fun getDetails(manga: Manga): Manga abstract suspend fun getDetails(manga: Manga): Manga
/** /**
* Parse pages list for specified chapter. * Parse pages list for specified chapter.
* @see MangaPage for details * @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. * Fetch direct link to the page image.
*/ */
public suspend fun getPageUrl(page: MangaPage): String open suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(domain)
public suspend fun getFilterOptions(): MangaListFilterOptions
/** /**
* Parse favicons from the main page of the source`s website * Fetch available tags (genres) for source
*/ */
public suspend fun getFavicons(): Favicons abstract suspend fun getAvailableTags(): Set<MangaTag>
public fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) /**
* Fetch available locales for multilingual sources
public suspend fun getRelatedManga(seed: Manga): List<Manga> */
open suspend fun getAvailableLocales(): Set<Locale> = emptySet()
public fun getRequestHeaders(): Headers @Deprecated(
message = "Use getAvailableTags instead",
replaceWith = ReplaceWith("getAvailableTags()"),
)
suspend fun getTags(): Set<MangaTag> = getAvailableTags()
/** /**
* Return [Manga] object by web link to it * Parse favicons from the main page of the source`s website
* @see [Manga.publicUrl]
*/ */
@InternalParsersApi open suspend fun getFavicons(): Favicons {
public suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? return FaviconParser(webClient, domain).parseFavicons()
}
@CallSuper
open fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
keys.add(configKeyDomain)
}
open suspend fun getRelatedManga(seed: Manga): List<Manga> {
return RelatedMangaFinder(listOf(this)).invoke(seed)
}
protected fun getParser(source: MangaSource) = if (this.source == source) {
this
} else {
context.newParserInstance(source)
}
} }

@ -6,19 +6,19 @@ import org.koitharu.kotatsu.parsers.exception.ParseException
/** /**
* Implement this in your parser for authorization support * 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. * Return link to the login page, which will be opened in browser.
* Must be an absolute url * Must be an absolute url
*/ */
public val authUrl: String val authUrl: String
/** /**
* Quick check if user is logged in. * Quick check if user is logged in.
* In most case you should check for cookies in [MangaLoaderContext.cookieJar]. * 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. * 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 [AuthRequiredException] if user is not logged in or authorization is expired
* @throws [ParseException] on parsing error * @throws [ParseException] on parsing error
*/ */
public suspend fun getUsername(): String suspend fun getUsername(): String
} }

@ -6,8 +6,7 @@ import org.koitharu.kotatsu.parsers.model.ContentType
* Annotate each [MangaParser] implementation with this annotation, used by codegen * Annotate each [MangaParser] implementation with this annotation, used by codegen
*/ */
@Target(AnnotationTarget.CLASS) @Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE) annotation class MangaSourceParser(
internal annotation class MangaSourceParser(
/** /**
* Name of manga source. Used as an Enum value, must be UPPER_CASE and unique. * Name of manga source. Used as an Enum value, must be UPPER_CASE and unique.
*/ */

@ -0,0 +1,89 @@
package org.koitharu.kotatsu.parsers
import androidx.annotation.RestrictTo
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.Paginator
@InternalParsersApi
abstract class PagedMangaParser(
context: MangaLoaderContext,
source: MangaSource,
@RestrictTo(RestrictTo.Scope.TESTS) @JvmField internal val pageSize: Int,
searchPageSize: Int = pageSize,
) : MangaParser(context, source) {
@JvmField
protected val paginator = Paginator(pageSize)
@JvmField
protected val searchPaginator = Paginator(searchPageSize)
final override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
return getList(
paginator = if (filter is MangaListFilter.Search) {
searchPaginator
} else {
paginator
},
offset = offset,
filter = filter,
)
}
@InternalParsersApi
@Deprecated("You should use getListPage for PagedMangaParser", level = DeprecationLevel.HIDDEN)
final override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
tagsExclude: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> = throw UnsupportedOperationException("You should use getListPage for PagedMangaParser")
open suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
tagsExclude: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> = throw NotImplementedError("Please implement getListPage(page, filter) instead")
open suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
return when (filter) {
is MangaListFilter.Advanced -> getListPage(
page = page,
query = null,
tags = filter.tags,
tagsExclude = filter.tagsExclude,
sortOrder = filter.sortOrder,
)
is MangaListFilter.Search -> getListPage(
page = page,
query = filter.query,
tags = null,
tagsExclude = null,
sortOrder = defaultSortOrder,
)
null -> getListPage(
page = page,
query = null,
tags = null,
tagsExclude = null,
sortOrder = defaultSortOrder,
)
}
}
private suspend fun getList(
paginator: Paginator,
offset: Int,
filter: MangaListFilter?,
): List<Manga> {
val page = paginator.getPage(offset)
val list = getListPage(page, filter)
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,13 +1,13 @@
package org.koitharu.kotatsu.parsers.config package org.koitharu.kotatsu.parsers.config
public sealed class ConfigKey<T>( sealed class ConfigKey<T>(
@JvmField public val key: String, @JvmField val key: String,
) { ) {
public abstract val defaultValue: T abstract val defaultValue: T
public class Domain( class Domain(
@JvmField @JvmSuppressWildcards public vararg val presetValues: String, @JvmField @JvmSuppressWildcards vararg val presetValues: String,
) : ConfigKey<String>("domain") { ) : ConfigKey<String>("domain") {
init { init {
@ -18,20 +18,15 @@ public sealed class ConfigKey<T>(
get() = presetValues.first() get() = presetValues.first()
} }
public class ShowSuspiciousContent( class ShowSuspiciousContent(
override val defaultValue: Boolean, override val defaultValue: Boolean,
) : ConfigKey<Boolean>("show_suspicious") ) : ConfigKey<Boolean>("show_suspicious")
public class UserAgent( class UserAgent(
override val defaultValue: String, override val defaultValue: String,
) : ConfigKey<String>("user_agent") ) : ConfigKey<String>("user_agent")
public class SplitByTranslations( class SplitByTranslations(
override val defaultValue: Boolean, override val defaultValue: Boolean,
) : ConfigKey<Boolean>("split_translations") ) : ConfigKey<Boolean>("split_translations")
public class PreferredImageServer(
public val presetValues: Map<String?, String?>,
override val defaultValue: String?,
) : ConfigKey<String?>("img_server")
} }

@ -1,6 +1,6 @@
package org.koitharu.kotatsu.parsers.config 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,12 @@
package org.koitharu.kotatsu.parsers.exception package org.koitharu.kotatsu.parsers.exception
import okio.IOException
import org.koitharu.kotatsu.parsers.InternalParsersApi import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
/** /**
* Authorization is required for access to the requested content * Authorization is required for access to the requested content
*/ */
public class AuthRequiredException @InternalParsersApi @JvmOverloads constructor( class AuthRequiredException @InternalParsersApi @JvmOverloads constructor(
public val source: MangaSource, val source: MangaSource,
cause: Throwable? = null, cause: Throwable? = null,
) : IOException("Authorization required", cause) ) : RuntimeException("Authorization required", cause)

@ -1,3 +1,3 @@
package org.koitharu.kotatsu.parsers.exception 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 okio.IOException
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.parsers.InternalParsersApi import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
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") it.getString("message")
} }

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

@ -2,8 +2,8 @@ package org.koitharu.kotatsu.parsers.exception
import org.koitharu.kotatsu.parsers.InternalParsersApi import org.koitharu.kotatsu.parsers.InternalParsersApi
public class ParseException @InternalParsersApi @JvmOverloads constructor( class ParseException @InternalParsersApi @JvmOverloads constructor(
public val shortMessage: String?, val shortMessage: String?,
public val url: String, val url: String,
cause: Throwable? = null, cause: Throwable? = null,
) : RuntimeException("$shortMessage at $url", cause) ) : 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 package org.koitharu.kotatsu.parsers.model
public const val RATING_UNKNOWN: Float = -1f const val RATING_UNKNOWN = -1f
public const val YEAR_UNKNOWN: Int = 0
public const val YEAR_MIN: Int = 1900
public const val YEAR_MAX: Int = 2099

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

@ -1,16 +1,12 @@
package org.koitharu.kotatsu.parsers.model package org.koitharu.kotatsu.parsers.model
public enum class ContentType { enum class ContentType {
/** /**
* Standard manga, manhua, webtoons, etc * Standard manga, manhua, webtoons, etc
*/ */
MANGA, MANGA,
MANHWA,
MANHUA,
/** /**
* Use this if the source provides mostly nsfw content. * Use this if the source provides mostly nsfw content.
*/ */
@ -21,16 +17,8 @@ public enum class ContentType {
*/ */
COMICS, COMICS,
NOVEL,
/** /**
* Use this type if no other suits your needs. For example, for an indie manga * 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, OTHER,
} }

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

@ -2,14 +2,14 @@ package org.koitharu.kotatsu.parsers.model
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
public data class Favicon( class Favicon internal constructor(
@JvmField public val url: String, @JvmField val url: String,
@JvmField public val size: Int, @JvmField val size: Int,
@JvmField internal val rel: String?, @JvmField internal val rel: String?,
) : Comparable<Favicon> { ) : Comparable<Favicon> {
@JvmField @JvmField
public val type: String = url.toHttpUrl().pathSegments.last() val type: String = url.toHttpUrl().pathSegments.last()
.substringAfterLast('.', "").lowercase() .substringAfterLast('.', "").lowercase()
override fun compareTo(other: Favicon): Int { override fun compareTo(other: Favicon): Int {
@ -20,6 +20,30 @@ public data class Favicon(
return relWeightOf(rel).compareTo(relWeightOf(other.rel)) 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) { private fun relWeightOf(rel: String?) = when (rel) {
"apple-touch-icon" -> 1 // Prefer apple-touch-icon because it has a better quality "apple-touch-icon" -> 1 // Prefer apple-touch-icon because it has a better quality
"mask-icon" -> -1 "mask-icon" -> -1

@ -1,8 +1,8 @@
package org.koitharu.kotatsu.parsers.model package org.koitharu.kotatsu.parsers.model
public class Favicons( class Favicons internal constructor(
favicons: Collection<Favicon>, favicons: Collection<Favicon>,
@JvmField public val referer: String?, @JvmField val referer: String,
) : Collection<Favicon> { ) : Collection<Favicon> {
private val icons = favicons.sortedDescending() private val icons = favicons.sortedDescending()
@ -18,7 +18,7 @@ public class Favicons(
override fun iterator(): Iterator<Favicon> = icons.iterator() override fun iterator(): Iterator<Favicon> = icons.iterator()
public operator fun minus(victim: Favicon): Favicons = Favicons( operator fun minus(victim: Favicon): Favicons = Favicons(
favicons = icons.filterNot { it == victim }, favicons = icons.filterNot { it == victim },
referer = referer, referer = referer,
) )
@ -30,7 +30,7 @@ public class Favicons(
* @param types supported file types, e.g. png, svg, ico. May be null but not empty * @param types supported file types, e.g. png, svg, ico. May be null but not empty
*/ */
@JvmOverloads @JvmOverloads
public fun find(size: Int, types: Set<String>? = null): Favicon? { fun find(size: Int, types: Set<String>? = null): Favicon? {
if (icons.isEmpty()) { if (icons.isEmpty()) {
return null return null
} }
@ -47,13 +47,4 @@ public class Favicons(
} }
return result 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,162 @@
package org.koitharu.kotatsu.parsers.model package org.koitharu.kotatsu.parsers.model
import androidx.collection.ArrayMap import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
public data class Manga( class Manga(
/** /**
* Unique identifier for manga * Unique identifier for manga
*/ */
@JvmField public val id: Long, @JvmField val id: Long,
/** /**
* Manga title, human-readable * Manga title, human-readable
*/ */
@JvmField public val title: String, @JvmField 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>, @JvmField val altTitle: String?,
/** /**
* Relative url to manga (**without** a domain) or any other uri. * Relative url to manga (**without** a domain) or any other uri.
* Used principally in parsers * Used principally in parsers
*/ */
@JvmField public val url: String, @JvmField val url: String,
/** /**
* Absolute url to manga, must be ready to open in browser * Absolute url to manga, must be ready to open in browser
*/ */
@JvmField public val publicUrl: String, @JvmField val publicUrl: String,
/** /**
* Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown * Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown
* @see hasRating * @see hasRating
*/ */
@JvmField public val rating: Float, @JvmField val rating: Float,
/** /**
* Indicates that manga may contain sensitive information (18+, NSFW) * Indicates that manga may contain sensitive information (18+, NSFW)
*/ */
@JvmField public val contentRating: ContentRating?, @JvmField val isNsfw: Boolean,
/** /**
* Absolute link to the cover * Absolute link to the cover
* @see largeCoverUrl * @see largeCoverUrl
*/ */
@JvmField public val coverUrl: String?, @JvmField val coverUrl: String,
/** /**
* Tags (genres) of the manga * Tags (genres) of the manga
*/ */
@JvmField public val tags: Set<MangaTag>, @JvmField val tags: Set<MangaTag>,
/** /**
* Manga status (ongoing, finished) or null if unknown * Manga status (ongoing, finished) or null if unknown
*/ */
@JvmField public val state: MangaState?, @JvmField val state: MangaState?,
/** /**
* Authors of the manga * Author of the manga, may be null
*/ */
@JvmField public val authors: Set<String>, @JvmField val author: String?,
/** /**
* Large cover url (absolute), null if is no large cover * Large cover url (absolute), null if is no large cover
* @see coverUrl * @see coverUrl
*/ */
@JvmField public val largeCoverUrl: String? = null, @JvmField val largeCoverUrl: String? = null,
/** /**
* Manga description, may be html or null * Manga description, may be html or null
*/ */
@JvmField public val description: String? = null, @JvmField val description: String? = null,
/** /**
* List of chapters * List of chapters
*/ */
@JvmField public val chapters: List<MangaChapter>? = null, @JvmField val chapters: List<MangaChapter>? = null,
/** /**
* Manga source * Manga source
*/ */
@JvmField public val source: MangaSource, @JvmField val source: MangaSource,
) { ) {
@Deprecated("Use other constructor") /**
public constructor( * Return if manga has a specified rating
/** * @see rating
* Unique identifier for manga */
*/ val hasRating: Boolean
id: Long, get() = rating > 0f && rating <= 1f
/**
* Manga title, human-readable fun getChapters(branch: String?): List<MangaChapter>? {
*/ return chapters?.filter { x -> x.branch == branch }
title: String, }
/**
* Alternative title (for example on other language), may be null @InternalParsersApi
*/ fun copy(
altTitle: String?, title: String = this.title,
/** altTitle: String? = this.altTitle,
* Relative url to manga (**without** a domain) or any other uri. publicUrl: String = this.publicUrl,
* Used principally in parsers rating: Float = this.rating,
*/ isNsfw: Boolean = this.isNsfw,
url: String, coverUrl: String = this.coverUrl,
/** tags: Set<MangaTag> = this.tags,
* Absolute url to manga, must be ready to open in browser state: MangaState? = this.state,
*/ author: String? = this.author,
publicUrl: String, largeCoverUrl: String? = this.largeCoverUrl,
/** description: String? = this.description,
* Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown chapters: List<MangaChapter>? = this.chapters,
* @see hasRating ) = Manga(
*/
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
*/
source: MangaSource,
) : this(
id = id, id = id,
title = title, title = title,
altTitles = setOfNotNull(altTitle?.nullIfEmpty()), altTitle = altTitle,
url = url, url = url,
publicUrl = publicUrl, publicUrl = publicUrl,
rating = rating, rating = rating,
contentRating = if (isNsfw) ContentRating.ADULT else null, isNsfw = isNsfw,
coverUrl = coverUrl?.nullIfEmpty(), coverUrl = coverUrl,
tags = tags, tags = tags,
state = state, state = state,
authors = setOfNotNull(author), author = author,
largeCoverUrl = largeCoverUrl?.nullIfEmpty(), largeCoverUrl = largeCoverUrl,
description = description?.nullIfEmpty(), description = description,
chapters = chapters, chapters = chapters,
source = source, source = source
) )
/** override fun equals(other: Any?): Boolean {
* Author of the manga, may be null if (this === other) return true
*/ if (javaClass != other?.javaClass) return false
@Deprecated("Please use authors")
public val author: String?
get() = authors.firstOrNull()
/**
* 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")) if (id != other.id) return false
public val isNsfw: Boolean if (title != other.title) return false
get() = contentRating == ContentRating.ADULT 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 true
return chapters?.filter { x -> x.branch == branch }.orEmpty()
} }
public fun findChapterById(id: Long): MangaChapter? = chapters?.findById(id) override fun hashCode(): Int {
var result = id.hashCode()
public fun requireChapterById(id: Long): MangaChapter = findChapterById(id) result = 31 * result + title.hashCode()
?: throw NoSuchElementException("Chapter with id $id not found") result = 31 * result + (altTitle?.hashCode() ?: 0)
result = 31 * result + url.hashCode()
public fun getBranches(): Map<String?, Int> { result = 31 * result + publicUrl.hashCode()
if (chapters.isNullOrEmpty()) { result = 31 * result + rating.hashCode()
return emptyMap() result = 31 * result + isNsfw.hashCode()
} result = 31 * result + coverUrl.hashCode()
val result = ArrayMap<String?, Int>() result = 31 * result + tags.hashCode()
chapters.forEach { result = 31 * result + (state?.hashCode() ?: 0)
val key = it.branch result = 31 * result + (author?.hashCode() ?: 0)
result[key] = result.getOrDefault(key, 0) + 1 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 return result
} }
override fun toString(): String {
return "Manga($id - \"$title\" [$url] - $source)"
}
} }

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

@ -1,88 +1,93 @@
package org.koitharu.kotatsu.parsers.model package org.koitharu.kotatsu.parsers.model
import org.koitharu.kotatsu.parsers.MangaParser
import java.util.* import java.util.*
public data class MangaListFilter( sealed interface MangaListFilter {
@JvmField val query: String? = null,
@JvmField val tags: Set<MangaTag> = emptySet(), fun isEmpty(): Boolean
@JvmField val tagsExclude: Set<MangaTag> = emptySet(),
@JvmField val locale: Locale? = null, val sortOrder: SortOrder?
@JvmField val originalLocale: Locale? = null,
@JvmField val states: Set<MangaState> = emptySet(), fun isValid(parser: MangaParser): Boolean = when (this) {
@JvmField val contentRating: Set<ContentRating> = emptySet(), is Advanced -> (sortOrder in parser.availableSortOrders) &&
@JvmField val types: Set<ContentType> = emptySet(), (tags.size <= 1 || parser.isMultipleTagsSupported) &&
@JvmField val demographics: Set<Demographic> = emptySet(), (tagsExclude.isEmpty() || parser.isTagsExclusionSupported) &&
@JvmField val year: Int = YEAR_UNKNOWN, (contentRating.isEmpty() || parser.availableContentRating.containsAll(contentRating)) &&
@JvmField val yearFrom: Int = YEAR_UNKNOWN, (states.isEmpty() || parser.availableStates.containsAll(states))
@JvmField val yearTo: Int = YEAR_UNKNOWN,
@JvmField val author: String? = null, is Search -> parser.isSearchSupported
) { }
private fun isNonSearchOptionsEmpty(): Boolean = tags.isEmpty() && data class Search(
tagsExclude.isEmpty() && @JvmField val query: String,
locale == null && ) : MangaListFilter {
originalLocale == null &&
states.isEmpty() && override val sortOrder: SortOrder? = null
contentRating.isEmpty() &&
year == YEAR_UNKNOWN && override fun isEmpty() = query.isBlank()
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 { data class Advanced(
private var query: String? = null override val sortOrder: SortOrder,
private val tags: MutableSet<MangaTag> = mutableSetOf() @JvmField val tags: Set<MangaTag>,
private val tagsExclude: MutableSet<MangaTag> = mutableSetOf() @JvmField val tagsExclude: Set<MangaTag>,
private var locale: Locale? = null @JvmField val locale: Locale?,
private var originalLocale: Locale? = null @JvmField val states: Set<MangaState>,
private val states: MutableSet<MangaState> = mutableSetOf() @JvmField val contentRating: Set<ContentRating>,
private val contentRating: MutableSet<ContentRating> = mutableSetOf() ) : MangaListFilter {
private val types: MutableSet<ContentType> = mutableSetOf()
private val demographics: MutableSet<Demographic> = mutableSetOf() override fun isEmpty(): Boolean =
private var year: Int = YEAR_UNKNOWN tags.isEmpty() && tagsExclude.isEmpty() && locale == null && states.isEmpty() && contentRating.isEmpty()
private var yearFrom: Int = YEAR_UNKNOWN
private var yearTo: Int = YEAR_UNKNOWN fun newBuilder() = Builder(sortOrder)
.tags(tags)
fun query(query: String?): Builder = apply { this.query = query } .tagsExclude(tagsExclude)
fun addTag(tag: MangaTag): Builder = apply { tags.add(tag) } .locale(locale)
fun addTags(tags: Collection<MangaTag>): Builder = apply { this.tags.addAll(tags) } .states(states)
fun excludeTag(tag: MangaTag): Builder = apply { tagsExclude.add(tag) } .contentRatings(contentRating)
fun excludeTags(tags: Collection<MangaTag>): Builder = apply { this.tagsExclude.addAll(tags) }
fun locale(locale: Locale?): Builder = apply { this.locale = locale } class Builder(sortOrder: SortOrder) {
fun originalLocale(locale: Locale?): Builder = apply { this.originalLocale = locale }
fun addState(state: MangaState): Builder = apply { states.add(state) } private var _sortOrder: SortOrder = sortOrder
fun addStates(states: Collection<MangaState>): Builder = apply { this.states.addAll(states) } private var _tags: Set<MangaTag>? = null
fun addContentRating(rating: ContentRating): Builder = apply { contentRating.add(rating) } private var _tagsExclude: Set<MangaTag>? = null
fun addContentRatings(ratings: Collection<ContentRating>): Builder = private var _locale: Locale? = null
apply { this.contentRating.addAll(ratings) } private var _states: Set<MangaState>? = null
private var _contentRating: Set<ContentRating>? = null
fun addType(type: ContentType): Builder = apply { types.add(type) }
fun addTypes(types: Collection<ContentType>): Builder = apply { this.types.addAll(types) } fun sortOrder(order: SortOrder) = apply {
fun addDemographic(demographic: Demographic): Builder = apply { demographics.add(demographic) } _sortOrder = order
fun addDemographics(demographics: Collection<Demographic>): Builder = }
apply { this.demographics.addAll(demographics) }
fun tags(tags: Set<MangaTag>?) = apply {
fun year(year: Int): Builder = apply { this.year = year } _tags = tags
fun yearFrom(year: Int): Builder = apply { this.yearFrom = year } }
fun yearTo(year: Int): Builder = apply { this.yearTo = year }
fun tagsExclude(tags: Set<MangaTag>?) = apply {
fun build(): MangaListFilter = MangaListFilter( _tagsExclude = tags
query, tags, tagsExclude, locale, originalLocale, states, }
contentRating, types, demographics, year, yearFrom, yearTo,
) fun locale(locale: Locale?) = apply {
_locale = locale
}
fun states(states: Set<MangaState>?) = apply {
_states = states
}
fun contentRatings(rating: Set<ContentRating>?) = apply {
_contentRating = rating
}
fun build() = Advanced(
sortOrder = _sortOrder,
tags = _tags.orEmpty(),
tagsExclude = _tagsExclude.orEmpty(),
locale = _locale,
states = _states.orEmpty(),
contentRating = _contentRating.orEmpty(),
)
}
} }
} }

@ -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,46 @@ package org.koitharu.kotatsu.parsers.model
import org.koitharu.kotatsu.parsers.MangaParser 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, @JvmField val id: Long,
/** /**
* Relative url to page (**without** a domain) or any other uri. * Relative url to page (**without** a domain) or any other uri.
* Used principally in parsers. * Used principally in parsers.
* May contain link to image or html page. * May contain link to image or html page.
* @see MangaParser.getPageUrl * @see MangaParser.getPageUrl
*/ */
@JvmField public val url: String, @JvmField val url: String,
/** /**
* Absolute url of the small page image if exists, null otherwise * Absolute url of the small page image if exists, null otherwise
*/ */
@JvmField public val preview: String?, @JvmField val preview: String?,
@JvmField public val source: MangaSource, @JvmField 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 (preview != other.preview) return false
return source == other.source
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + url.hashCode()
result = 31 * result + (preview?.hashCode() ?: 0)
result = 31 * result + source.hashCode()
return result
}
override fun toString(): String {
return "MangaPage($id [$url] - $source)"
}
}

@ -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 package org.koitharu.kotatsu.parsers.model
public enum class MangaState { enum class MangaState {
ONGOING, FINISHED, ABANDONED, PAUSED, UPCOMING, RESTRICTED ONGOING, FINISHED, ABANDONED, PAUSED, UPCOMING
} }

@ -2,15 +2,40 @@ package org.koitharu.kotatsu.parsers.model
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
public data class MangaTag( class MangaTag(
/** /**
* User-readable tag title, should be in Title case * User-readable tag title, should be in Title case
*/ */
@JvmField public val title: String, @JvmField val title: String,
/** /**
* Identifier of a tag, must be unique among the source. * Identifier of a tag, must be unique among the source.
* @see MangaParser.getList * @see MangaParser.getList
*/ */
@JvmField public val key: String, @JvmField val key: String,
@JvmField public val source: MangaSource, @JvmField 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
}
override fun toString(): String {
return "MangaTag($key \"$title\" - $source)"
}
}

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

@ -3,9 +3,9 @@ package org.koitharu.kotatsu.parsers.model
import org.koitharu.kotatsu.parsers.InternalParsersApi import org.koitharu.kotatsu.parsers.InternalParsersApi
@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) } fun anyWordIn(dateString: String): Boolean = words.any {
public fun startsWith(dateString: String): Boolean = words.any { dateString.startsWith(it, ignoreCase = true) } dateString.contains(it, ignoreCase = true)
public fun endsWith(dateString: String): Boolean = words.any { dateString.endsWith(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"
}
}

@ -13,7 +13,7 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseJson import org.koitharu.kotatsu.parsers.util.parseJson
import java.net.HttpURLConnection import java.net.HttpURLConnection
public class OkHttpWebClient( class OkHttpWebClient(
private val httpClient: OkHttpClient, private val httpClient: OkHttpClient,
private val mangaSource: MangaSource, private val mangaSource: MangaSource,
) : WebClient { ) : WebClient {

@ -1,17 +1,14 @@
package org.koitharu.kotatsu.parsers.network package org.koitharu.kotatsu.parsers.network
public object UserAgents { object UserAgents {
public const val CHROME_MOBILE: String = const val CHROME_MOBILE =
"Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.196 Mobile Safari/537.36" "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 = const val CHROME_DESKTOP =
"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" "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" const val FIREFOX_DESKTOP = "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)" const val KOTATSU = "Kotatsu/5.3 (Android 13;;; en)"
} }

@ -6,55 +6,54 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response import okhttp3.Response
import org.json.JSONObject import org.json.JSONObject
public interface WebClient { interface WebClient {
/** /**
* Do a GET http request to specific url * Do a GET http request to specific url
* @param url * @param url
*/ */
public suspend fun httpGet(url: String): Response = httpGet(url.toHttpUrl()) suspend fun httpGet(url: String): Response = httpGet(url.toHttpUrl())
public suspend fun httpGet(url: String, extraHeaders: Headers?): Response = httpGet(url.toHttpUrl(), extraHeaders) suspend fun httpGet(url: String, extraHeaders: Headers?): Response = httpGet(url.toHttpUrl(), extraHeaders)
/** /**
* Do a GET http request to specific url * Do a GET http request to specific url
* @param url * @param url
*/ */
public suspend fun httpGet(url: HttpUrl): Response = httpGet(url, null) suspend fun httpGet(url: HttpUrl): Response = httpGet(url, null)
/** /**
* Do a GET http request to specific url * Do a GET http request to specific url
* @param url * @param url
* @param extraHeaders additional HTTP headers for request * @param extraHeaders additional HTTP headers for request
*/ */
public suspend fun httpGet(url: HttpUrl, extraHeaders: Headers?): Response suspend fun httpGet(url: HttpUrl, extraHeaders: Headers?): Response
/** /**
* Do a HEAD http request to specific url * Do a HEAD http request to specific url
* @param url * @param url
*/ */
public suspend fun httpHead(url: String): Response = httpHead(url.toHttpUrl()) suspend fun httpHead(url: String): Response = httpHead(url.toHttpUrl())
/** /**
* Do a HEAD http request to specific url * Do a HEAD http request to specific url
* @param url * @param url
*/ */
public suspend fun httpHead(url: HttpUrl): Response suspend fun httpHead(url: HttpUrl): Response
/** /**
* Do a POST http request to specific url with `multipart/form-data` payload * Do a POST http request to specific url with `multipart/form-data` payload
* @param url * @param url
* @param form payload as key=>value map * @param form payload as key=>value map
*/ */
public suspend fun httpPost(url: String, form: Map<String, String>): Response = suspend fun httpPost(url: String, form: Map<String, String>): Response = httpPost(url.toHttpUrl(), form, null)
httpPost(url.toHttpUrl(), form, null)
/** /**
* Do a POST http request to specific url with `multipart/form-data` payload * Do a POST http request to specific url with `multipart/form-data` payload
* @param url * @param url
* @param form payload as key=>value map * @param form payload as key=>value map
*/ */
public suspend fun httpPost(url: HttpUrl, form: Map<String, String>): Response = httpPost(url, form, null) 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 * Do a POST http request to specific url with `multipart/form-data` payload
@ -62,21 +61,21 @@ public interface WebClient {
* @param form payload as key=>value map * @param form payload as key=>value map
* @param extraHeaders additional HTTP headers for request * @param extraHeaders additional HTTP headers for request
*/ */
public suspend fun httpPost(url: HttpUrl, form: Map<String, String>, extraHeaders: Headers?): Response 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 * Do a POST http request to specific url with `multipart/form-data` payload
* @param url * @param url
* @param payload payload as `key=value` string with `&` separator * @param payload payload as `key=value` string with `&` separator
*/ */
public suspend fun httpPost(url: String, payload: String): Response = httpPost(url.toHttpUrl(), payload, null) 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 * Do a POST http request to specific url with `multipart/form-data` payload
* @param url * @param url
* @param payload payload as `key=value` string with `&` separator * @param payload payload as `key=value` string with `&` separator
*/ */
public suspend fun httpPost(url: HttpUrl, payload: String): Response = httpPost(url, payload, null) 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 * Do a POST http request to specific url with `multipart/form-data` payload
@ -84,21 +83,21 @@ public interface WebClient {
* @param payload payload as `key=value` string with `&` separator * @param payload payload as `key=value` string with `&` separator
* @param extraHeaders additional HTTP headers for request * @param extraHeaders additional HTTP headers for request
*/ */
public suspend fun httpPost(url: HttpUrl, payload: String, extraHeaders: Headers?): Response suspend fun httpPost(url: HttpUrl, payload: String, extraHeaders: Headers?): Response
/** /**
* Do a POST http request to specific url with json payload * Do a POST http request to specific url with json payload
* @param url * @param url
* @param body * @param body
*/ */
public suspend fun httpPost(url: String, body: JSONObject): Response = httpPost(url.toHttpUrl(), body, null) suspend fun httpPost(url: String, body: JSONObject): Response = httpPost(url.toHttpUrl(), body, null)
/** /**
* Do a POST http request to specific url with json payload * Do a POST http request to specific url with json payload
* @param url * @param url
* @param body * @param body
*/ */
public suspend fun httpPost(url: HttpUrl, body: JSONObject): Response = httpPost(url, body, null) suspend fun httpPost(url: HttpUrl, body: JSONObject): Response = httpPost(url, body, null)
/** /**
* Do a POST http request to specific url with json payload * Do a POST http request to specific url with json payload
@ -106,12 +105,12 @@ public interface WebClient {
* @param body * @param body
* @param extraHeaders additional HTTP headers for request * @param extraHeaders additional HTTP headers for request
*/ */
public suspend fun httpPost(url: HttpUrl, body: JSONObject, extraHeaders: Headers?): Response suspend fun httpPost(url: HttpUrl, body: JSONObject, extraHeaders: Headers?): Response
/** /**
* Do a GraphQL request to specific url * Do a GraphQL request to specific url
* @param endpoint an url * @param endpoint an url
* @param query GraphQL request payload * @param query GraphQL request payload
*/ */
public suspend fun graphQLQuery(endpoint: String, query: String): JSONObject suspend fun graphQLQuery(endpoint: String, query: String): JSONObject
} }

@ -5,13 +5,13 @@ import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey 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.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.isNullOrEmpty
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.MessageDigest import java.security.MessageDigest
import java.util.* import java.util.*
@ -22,77 +22,23 @@ import javax.crypto.spec.SecretKeySpec
@MangaSourceParser("BATOTO", "Bato.To") @MangaSourceParser("BATOTO", "Bato.To")
internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser( internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
context = context, context = context,
source = MangaParserSource.BATOTO, source = MangaSource.BATOTO,
pageSize = 60, pageSize = 60,
searchPageSize = 20, 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( override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.ALPHABETICAL,
SortOrder.UPDATED,
SortOrder.NEWEST, SortOrder.NEWEST,
SortOrder.UPDATED,
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.POPULARITY_YEAR, SortOrder.ALPHABETICAL,
SortOrder.POPULARITY_MONTH,
SortOrder.POPULARITY_WEEK,
SortOrder.POPULARITY_TODAY,
SortOrder.POPULARITY_HOUR,
) )
override val filterCapabilities: MangaListFilterCapabilities override val availableStates: Set<MangaState> = EnumSet.allOf(MangaState::class.java)
get() = MangaListFilterCapabilities(
isMultipleTagsSupported = true,
isTagsExclusionSupported = true,
isSearchSupported = true,
isOriginalLocaleSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions( override val isTagsExclusionSupported: Boolean = true
availableTags = fetchAvailableTags(),
availableStates = EnumSet.of( override val availableContentRating: Set<ContentRating> = EnumSet.of(ContentRating.SAFE)
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"),
),
)
override val configKeyDomain = ConfigKey.Domain( override val configKeyDomain = ConfigKey.Domain(
"bato.to", "bato.to",
@ -120,33 +66,27 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
"zbato.com", "zbato.com",
"zbato.net", "zbato.net",
"zbato.org", "zbato.org",
"fto.to",
"jto.to",
) )
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> { override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
when {
!filter.query.isNullOrEmpty() -> { when (filter) {
is MangaListFilter.Search -> {
return search(page, filter.query) return search(page, filter.query)
} }
else -> { is MangaListFilter.Advanced -> {
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/browse?sort=") append("/browse?sort=")
when (order) { when (filter.sortOrder) {
SortOrder.UPDATED -> append("update.za") SortOrder.UPDATED -> append("update.za")
SortOrder.POPULARITY -> append("views_a.za") SortOrder.POPULARITY -> append("views_a.za")
SortOrder.NEWEST -> append("create.za") SortOrder.NEWEST -> append("create.za")
SortOrder.ALPHABETICAL -> append("title.az") 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") else -> append("update.za")
} }
@ -159,38 +99,23 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
MangaState.ABANDONED -> "cancelled" MangaState.ABANDONED -> "cancelled"
MangaState.PAUSED -> "hiatus" MangaState.PAUSED -> "hiatus"
MangaState.UPCOMING -> "pending" MangaState.UPCOMING -> "pending"
else -> throw IllegalArgumentException("$it not supported")
}, },
) )
} }
filter.locale?.let { filter.locale?.let {
append("&langs=") append("&langs=")
if (it.language == "in") { append(it.language)
append("id")
} else {
append(it.language)
}
}
filter.originalLocale?.let {
append("&origs=")
if (it.language == "in") {
append("id")
} else {
append(it.language)
}
} }
append("&genres=") append("&genres=")
if (filter.tags.isNotEmpty()) { if (filter.tags.isNotEmpty()) {
filter.tags.joinTo(this, ",") { it.key } appendAll(filter.tags, ",") { it.key }
} }
append("|") append("|")
if (filter.tagsExclude.isNotEmpty()) { if (filter.tagsExclude.isNotEmpty()) {
filter.tagsExclude.joinTo(this, ",") { it.key } appendAll(filter.tagsExclude, ",") { it.key }
} }
if (filter.contentRating.isNotEmpty()) { if (filter.contentRating.isNotEmpty()) {
@ -210,6 +135,17 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
return parseList(url, page) return parseList(url, page)
} }
null -> {
val url = buildString {
append("https://")
append(domain)
append("/browse?sort=update.za")
append("&page=")
append(page.toString())
}
return parseList(url, page)
}
} }
} }
@ -218,16 +154,11 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
.requireElementById("mainer") .requireElementById("mainer")
val details = root.selectFirstOrThrow(".detail-set") val details = root.selectFirstOrThrow(".detail-set")
val attrs = details.selectFirst(".attr-main")?.select(".attr-item")?.associate { 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() }.orEmpty()
val author = attrs["Authors:"]?.textOrNull()
return manga.copy( return manga.copy(
title = root.selectFirst("h3.item-title")?.text() ?: manga.title, title = root.selectFirst("h3.item-title")?.text() ?: manga.title,
contentRating = if (root.selectFirst("alert")?.getElementsContainingOwnText("NSFW").isNullOrEmpty()) { isNsfw = !root.selectFirst("alert")?.getElementsContainingOwnText("NSFW").isNullOrEmpty(),
ContentRating.ADULT
} else {
ContentRating.SAFE
},
largeCoverUrl = details.selectFirst("img[src]")?.absUrl("src"), largeCoverUrl = details.selectFirst("img[src]")?.absUrl("src"),
description = details.getElementById("limit-height-body-summary") description = details.getElementById("limit-height-body-summary")
?.selectFirst(".limit-html") ?.selectFirst(".limit-html")
@ -240,7 +171,7 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
"Hiatus" -> MangaState.PAUSED "Hiatus" -> MangaState.PAUSED
else -> manga.state else -> manga.state
}, },
authors = author?.let { setOf(it) } ?: manga.authors, author = attrs["Authors:"]?.text()?.trim() ?: manga.author,
chapters = root.selectFirst(".episode-list") chapters = root.selectFirst(".episode-list")
?.selectFirst(".main") ?.selectFirst(".main")
?.children() ?.children()
@ -285,7 +216,7 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
throw ParseException("Cannot find images list", fullUrl) throw ParseException("Cannot find images list", fullUrl)
} }
private suspend fun fetchAvailableTags(): Set<MangaTag> { override suspend fun getAvailableTags(): Set<MangaTag> {
val scripts = webClient.httpGet( val scripts = webClient.httpGet(
"https://${domain}/browse", "https://${domain}/browse",
).parseHtml().selectOrThrow("script") ).parseHtml().selectOrThrow("script")
@ -306,6 +237,22 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
throw ParseException("Cannot find gernes list", scripts[0].baseUri()) throw ParseException("Cannot find gernes list", scripts[0].baseUri())
} }
override suspend fun getAvailableLocales(): Set<Locale> = 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"),
)
private suspend fun search(page: Int, query: String): List<Manga> { private suspend fun search(page: Int, query: String): List<Manga> {
val url = buildString { val url = buildString {
append("https://") append("https://")
@ -340,17 +287,17 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
Manga( Manga(
id = generateUid(href), id = generateUid(href),
title = title, title = title,
altTitles = setOfNotNull(div.selectFirst(".item-alias")?.textOrNull()?.takeUnless { it == title }), altTitle = div.selectFirst(".item-alias")?.text()?.takeUnless { it == title },
url = href, url = href,
publicUrl = a.absUrl("href"), publicUrl = a.absUrl("href"),
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
contentRating = null, isNsfw = false,
coverUrl = div.selectFirst("img[src]")?.absUrl("src"), coverUrl = div.selectFirst("img[src]")?.absUrl("src").orEmpty(),
largeCoverUrl = null, largeCoverUrl = null,
description = null, description = null,
tags = div.selectFirst(".item-genre")?.parseTags().orEmpty(), tags = div.selectFirst(".item-genre")?.parseTags().orEmpty(),
state = null, state = null,
authors = emptySet(), author = null,
source = source, source = source,
) )
} }
@ -371,9 +318,8 @@ internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
return MangaChapter( return MangaChapter(
id = generateUid(href), id = generateUid(href),
title = a.textOrNull(), name = a.text(),
number = index + 1f, number = index + 1,
volume = 0,
url = href, url = href,
scanlator = extra?.getElementsByAttributeValueContaining("href", "/group/")?.text(), scanlator = extra?.getElementsByAttributeValueContaining("href", "/group/")?.text(),
uploadDate = runCatching { uploadDate = runCatching {

@ -2,68 +2,41 @@ package org.koitharu.kotatsu.parsers.site.all
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.collection.SparseArrayCompat import androidx.collection.SparseArrayCompat
import okhttp3.HttpUrl
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.* import org.koitharu.kotatsu.parsers.util.json.*
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
/**
* cc
*/
private const val CHAPTERS_LIMIT = 99999 private const val CHAPTERS_LIMIT = 99999
@MangaSourceParser("COMICK_FUN", "ComicK") @MangaSourceParser("COMICK_FUN", "ComicK")
internal class ComickFunParser(context: MangaLoaderContext) : internal class ComickFunParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.COMICK_FUN, 20) {
PagedMangaParser(context, MangaParserSource.COMICK_FUN, 20) {
override val configKeyDomain = ConfigKey.Domain("comick.io")
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) { override val configKeyDomain = ConfigKey.Domain("comick.cc")
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val availableSortOrders: Set<SortOrder> = EnumSet.of( override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.UPDATED, SortOrder.UPDATED,
SortOrder.RATING, SortOrder.RATING,
SortOrder.NEWEST,
) )
override val filterCapabilities: MangaListFilterCapabilities override val availableStates: Set<MangaState> =
get() = MangaListFilterCapabilities( EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED, MangaState.ABANDONED)
isMultipleTagsSupported = true,
isTagsExclusionSupported = true,
isSearchSupported = true,
isSearchWithFiltersSupported = true,
isYearRangeSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions( private val tagsArray = SuspendLazy(::loadTags)
availableTags = fetchAvailableTags(),
availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED, MangaState.ABANDONED),
availableContentTypes = EnumSet.of(
ContentType.MANGA,
ContentType.MANHWA,
ContentType.MANHUA,
ContentType.OTHER,
),
availableDemographics = EnumSet.of(
Demographic.SHOUNEN,
Demographic.SHOUJO,
Demographic.SEINEN,
Demographic.JOSEI,
Demographic.NONE,
),
)
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> { override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
val domain = domain val domain = domain
val url = urlBuilder() val url = urlBuilder()
.host("api.$domain") .host("api.$domain")
@ -73,78 +46,41 @@ internal class ComickFunParser(context: MangaLoaderContext) :
.addQueryParameter("tachiyomi", "true") .addQueryParameter("tachiyomi", "true")
.addQueryParameter("limit", pageSize.toString()) .addQueryParameter("limit", pageSize.toString())
.addQueryParameter("page", page.toString()) .addQueryParameter("page", page.toString())
when (filter) {
is MangaListFilter.Search -> {
url.addQueryParameter("q", filter.query)
}
filter.query?.let { null -> {
url.addQueryParameter("q", filter.query) url.addQueryParameter("sort", "view")
} }
filter.tags.forEach {
url.addQueryParameter("genres", it.key)
}
filter.tagsExclude.forEach {
url.addQueryParameter("excludes", it.key)
}
url.addQueryParameter(
"sort",
when (order) {
SortOrder.NEWEST -> "created_at"
SortOrder.POPULARITY -> "view"
SortOrder.RATING -> "rating"
SortOrder.UPDATED -> "uploaded"
else -> "uploaded"
},
)
filter.states.oneOrThrowIfMany()?.let {
url.addQueryParameter(
"status",
when (it) {
MangaState.ONGOING -> "1"
MangaState.FINISHED -> "2"
MangaState.ABANDONED -> "3"
MangaState.PAUSED -> "4"
else -> ""
},
)
}
if (filter.yearFrom != YEAR_UNKNOWN) {
url.addQueryParameter("from", filter.yearFrom.toString())
}
if (filter.yearTo != YEAR_UNKNOWN) {
url.addQueryParameter("to", filter.yearTo.toString())
}
filter.types.forEach {
url.addQueryParameter(
"country",
when (it) {
ContentType.MANGA -> "jp"
ContentType.MANHWA -> "kr"
ContentType.MANHUA -> "cn"
ContentType.OTHER -> "others"
else -> ""
},
)
}
filter.demographics.forEach { is MangaListFilter.Advanced -> {
url.addQueryParameter( filter.tags.forEach { tag ->
"demographic", url.addQueryParameter("genres", tag.key)
when (it) { }
Demographic.SHOUNEN -> "1" url.addQueryParameter(
Demographic.SHOUJO -> "2" "sort",
Demographic.SEINEN -> "3" when (filter.sortOrder) {
Demographic.JOSEI -> "4" SortOrder.POPULARITY -> "view"
Demographic.NONE -> "5" SortOrder.RATING -> "rating"
else -> "" else -> "uploaded"
}, },
) )
filter.states.oneOrThrowIfMany()?.let {
url.addQueryParameter(
"status",
when (it) {
MangaState.ONGOING -> "1"
MangaState.FINISHED -> "2"
MangaState.ABANDONED -> "3"
MangaState.PAUSED -> "4"
else -> ""
},
)
}
}
} }
val ja = webClient.httpGet(url.build()).parseJsonArray() val ja = webClient.httpGet(url.build()).parseJsonArray()
val tagsMap = tagsArray.get() val tagsMap = tagsArray.get()
return ja.mapJSON { jo -> return ja.mapJSON { jo ->
@ -152,12 +88,12 @@ internal class ComickFunParser(context: MangaLoaderContext) :
Manga( Manga(
id = generateUid(slug), id = generateUid(slug),
title = jo.getString("title"), title = jo.getString("title"),
altTitles = emptySet(), altTitle = null,
url = slug, url = slug,
publicUrl = "https://$domain/comic/$slug", publicUrl = "https://$domain/comic/$slug",
rating = jo.getDoubleOrDefault("rating", -10.0).toFloat() / 10f, rating = jo.getDoubleOrDefault("rating", -10.0).toFloat() / 10f,
contentRating = null, isNsfw = false,
coverUrl = jo.getStringOrNull("cover_url"), coverUrl = jo.getString("cover_url"),
largeCoverUrl = null, largeCoverUrl = null,
description = jo.getStringOrNull("desc"), description = jo.getStringOrNull("desc"),
tags = jo.selectGenres(tagsMap), tags = jo.selectGenres(tagsMap),
@ -168,7 +104,7 @@ internal class ComickFunParser(context: MangaLoaderContext) :
4 -> MangaState.PAUSED 4 -> MangaState.PAUSED
else -> null else -> null
}, },
authors = emptySet(), author = null,
source = source, source = source,
) )
} }
@ -179,17 +115,11 @@ internal class ComickFunParser(context: MangaLoaderContext) :
val url = "https://api.$domain/comic/${manga.url}?tachiyomi=true" val url = "https://api.$domain/comic/${manga.url}?tachiyomi=true"
val jo = webClient.httpGet(url).parseJson() val jo = webClient.httpGet(url).parseJson()
val comic = jo.getJSONObject("comic") val comic = jo.getJSONObject("comic")
val alt = comic.getJSONArray("md_titles").asTypedList<JSONObject>().mapNotNullToSet { var alt = ""
it.getStringOrNull("title") comic.getJSONArray("md_titles").mapJSON { alt += it.getString("title") + " - " }
}
val authors = jo.getJSONArray("artists").mapJSONNotNullToSet { it.getStringOrNull("name") }
return manga.copy( return manga.copy(
altTitles = alt, altTitle = alt.ifEmpty { comic.getStringOrNull("title") },
contentRating = when { isNsfw = jo.getBoolean("matureContent") || comic.getBoolean("hentai"),
comic.getBooleanOrDefault("hentai", false) -> ContentRating.ADULT
jo.getBooleanOrDefault("matureContent", false) -> ContentRating.SUGGESTIVE
else -> ContentRating.SAFE
},
description = comic.getStringOrNull("parsed") ?: comic.getStringOrNull("desc"), description = comic.getStringOrNull("parsed") ?: comic.getStringOrNull("desc"),
tags = manga.tags + comic.getJSONArray("md_comic_md_genres").mapJSONToSet { tags = manga.tags + comic.getJSONArray("md_comic_md_genres").mapJSONToSet {
val g = it.getJSONObject("md_genres") val g = it.getJSONObject("md_genres")
@ -199,44 +129,11 @@ internal class ComickFunParser(context: MangaLoaderContext) :
source = source, source = source,
) )
}, },
authors = authors, author = jo.getJSONArray("artists").optJSONObject(0)?.getString("name"),
chapters = getChapters(comic.getString("hid")), chapters = getChapters(comic.getString("hid")),
) )
} }
private suspend fun getChapters(hid: String): List<MangaChapter> {
val ja = webClient.httpGet(
url = "https://api.${domain}/comic/$hid/chapters?limit=$CHAPTERS_LIMIT",
).parseJson().getJSONArray("chapters")
val dateFormat = SimpleDateFormat("yyyy-MM-dd")
return ja.asTypedList<JSONObject>().reversed().mapChapters { _, jo ->
val vol = jo.getIntOrDefault("vol", 0)
val chap = jo.getFloatOrDefault("chap", 0f)
val locale = Locale.forLanguageTag(jo.getString("lang"))
val group = jo.optJSONArray("group_name")?.joinToString(", ")
val branch = buildString {
append(locale.getDisplayName(locale).toTitleCase(locale))
if (!group.isNullOrEmpty()) {
append(" (")
append(group)
append(')')
}
}
MangaChapter(
id = generateUid(jo.getLong("id")),
title = jo.getStringOrNull("title"),
number = chap,
volume = vol,
url = jo.getString("hid"),
scanlator = jo.optJSONArray("group_name")?.asTypedList<String>()?.joinToString()
?.takeUnless { it.isBlank() },
uploadDate = dateFormat.parseSafe(jo.getString("created_at").substringBefore('T')),
branch = branch,
source = source,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val jo = webClient.httpGet( val jo = webClient.httpGet(
"https://api.${domain}/chapter/${chapter.url}?tachiyomi=true", "https://api.${domain}/chapter/${chapter.url}?tachiyomi=true",
@ -252,14 +149,7 @@ internal class ComickFunParser(context: MangaLoaderContext) :
} }
} }
override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? { override suspend fun getAvailableTags(): Set<MangaTag> {
val slug = link.pathSegments.lastOrNull() ?: return null
return resolver.resolveManga(this, url = slug, id = generateUid(slug))
}
private val tagsArray = suspendLazy(initializer = ::loadTags)
private suspend fun fetchAvailableTags(): Set<MangaTag> {
val sparseArray = tagsArray.get() val sparseArray = tagsArray.get()
val set = ArraySet<MangaTag>(sparseArray.size()) val set = ArraySet<MangaTag>(sparseArray.size())
for (i in 0 until sparseArray.size()) { for (i in 0 until sparseArray.size()) {
@ -271,7 +161,7 @@ internal class ComickFunParser(context: MangaLoaderContext) :
private suspend fun loadTags(): SparseArrayCompat<MangaTag> { private suspend fun loadTags(): SparseArrayCompat<MangaTag> {
val ja = webClient.httpGet("https://api.${domain}/genre").parseJsonArray() val ja = webClient.httpGet("https://api.${domain}/genre").parseJsonArray()
val tags = SparseArrayCompat<MangaTag>(ja.length()) val tags = SparseArrayCompat<MangaTag>(ja.length())
for (jo in ja.asTypedList<JSONObject>()) { for (jo in ja.JSONIterator()) {
tags.append( tags.append(
jo.getInt("id"), jo.getInt("id"),
MangaTag( MangaTag(
@ -284,6 +174,77 @@ internal class ComickFunParser(context: MangaLoaderContext) :
return tags return tags
} }
private suspend fun getChapters(hid: String): List<MangaChapter> {
val ja = webClient.httpGet(
url = "https://api.${domain}/comic/$hid/chapters?limit=$CHAPTERS_LIMIT",
).parseJson().getJSONArray("chapters")
val dateFormat = SimpleDateFormat("yyyy-MM-dd")
val counters = HashMap<String?, Int>()
return ja.toJSONList().reversed().mapChapters { _, jo ->
val vol = jo.getStringOrNull("vol")
val chap = jo.getStringOrNull("chap")
val locale = Locale.forLanguageTag(jo.getString("lang"))
val group = jo.optJSONArray("group_name")?.joinToString(", ")
val branch = buildString {
append(locale.getDisplayName(locale).toTitleCase(locale))
if (!group.isNullOrEmpty()) {
append(" (")
append(group)
append(')')
}
}
MangaChapter(
id = generateUid(jo.getLong("id")),
name = buildString {
vol?.let { append("Vol ").append(it).append(' ') }
chap?.let { append("Chap ").append(it) }
jo.getStringOrNull("title")?.let { append(": ").append(it) }
},
number = counters.incrementAndGet(branch),
url = jo.getString("hid"),
scanlator = jo.optJSONArray("group_name")?.asIterable<String>()?.joinToString()
?.takeUnless { it.isBlank() },
uploadDate = dateFormat.tryParse(jo.getString("created_at").substringBefore('T')),
branch = branch,
source = source,
)
}
/*val chaptersBuilder = ChaptersListBuilder(list.size)
val branchedChapters = HashMap<String?, HashMap<Pair<String?, String?>, MangaChapter>>()
for (jo in list) {
val vol = jo.getStringOrNull("vol")
val chap = jo.getStringOrNull("chap")
val volChap = vol to chap
val locale = Locale.forLanguageTag(jo.getString("lang"))
val lc = locale.getDisplayName(locale).toTitleCase(locale)
val branch = (list.indices).firstNotNullOf { i ->
val b = if (i == 0) lc else "$lc ($i)"
if (branchedChapters[b]?.get(volChap) == null) b else null
}
val chapter = MangaChapter(
id = generateUid(jo.getLong("id")),
name = buildString {
vol?.let { append("Vol ").append(it).append(' ') }
chap?.let { append("Chap ").append(it) }
jo.getStringOrNull("title")?.let { append(": ").append(it) }
},
number = branchedChapters[branch]?.size?.plus(1) ?: 1,
url = jo.getString("hid"),
scanlator = jo.optJSONArray("group_name")?.asIterable<String>()?.joinToString()
?.takeUnless { it.isBlank() },
uploadDate = dateFormat.tryParse(jo.getString("created_at").substringBefore('T')),
branch = branch,
source = source,
)
if (chaptersBuilder.add(chapter)) {
branchedChapters.getOrPut(branch, ::HashMap)[volChap] = chapter
}
}
return chaptersBuilder.toList()*/
}
private fun JSONObject.selectGenres(tags: SparseArrayCompat<MangaTag>): Set<MangaTag> { private fun JSONObject.selectGenres(tags: SparseArrayCompat<MangaTag>): Set<MangaTag> {
val array = optJSONArray("genres") ?: return emptySet() val array = optJSONArray("genres") ?: return emptySet()
val res = ArraySet<MangaTag>(array.length()) val res = ArraySet<MangaTag>(array.length())

@ -1,497 +1,420 @@
package org.koitharu.kotatsu.parsers.site.all package org.koitharu.kotatsu.parsers.site.all
import androidx.collection.ArrayMap
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.collection.MutableIntLongMap import androidx.collection.SparseArrayCompat
import androidx.collection.MutableIntObjectMap import androidx.collection.set
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.jsoup.internal.StringUtil import org.jsoup.internal.StringUtil
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.bitmap.Rect import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.Collections.emptyList import kotlin.math.pow
import java.util.concurrent.TimeUnit
private const val DOMAIN_UNAUTHORIZED = "e-hentai.org" private const val DOMAIN_UNAUTHORIZED = "e-hentai.org"
private const val DOMAIN_AUTHORIZED = "exhentai.org" private const val DOMAIN_AUTHORIZED = "exhentai.org"
private val TAG_PREFIXES = arrayOf("male:", "female:", "other:")
private const val BANNED_RESPONSE_LENGTH = 256L
@MangaSourceParser("EXHENTAI", "ExHentai", type = ContentType.HENTAI) @MangaSourceParser("EXHENTAI", "ExHentai", type = ContentType.HENTAI)
internal class ExHentaiParser( internal class ExHentaiParser(
context: MangaLoaderContext, context: MangaLoaderContext,
) : PagedMangaParser(context, MangaParserSource.EXHENTAI, pageSize = 25), MangaParserAuthProvider, Interceptor { ) : PagedMangaParser(context, MangaSource.EXHENTAI, pageSize = 25), MangaParserAuthProvider {
override val availableSortOrders: Set<SortOrder> = setOf(SortOrder.NEWEST) override val availableSortOrders: Set<SortOrder> = setOf(SortOrder.NEWEST)
override val isTagsExclusionSupported: Boolean = true
override val configKeyDomain: ConfigKey.Domain
get() { override val configKeyDomain: ConfigKey.Domain
val isAuthorized = checkAuth() get() = ConfigKey.Domain(
return ConfigKey.Domain( if (isAuthorized) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED,
if (isAuthorized) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED, if (isAuthorized) DOMAIN_UNAUTHORIZED else DOMAIN_AUTHORIZED,
if (isAuthorized) DOMAIN_UNAUTHORIZED else DOMAIN_AUTHORIZED, )
)
} override val authUrl: String
get() = "https://${domain}/bounce_login.php"
override val authUrl: String
get() = "https://${domain}/bounce_login.php" private val ratingPattern = Regex("-?[0-9]+px")
private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash")
private val ratingPattern = Regex("-?[0-9]+px") private var updateDm = false
private val titleCleanupPattern = Regex("(\\[.*?]|\\([C0-9]*\\))") private val nextPages = SparseArrayCompat<Long>()
private val spacesCleanupPattern = Regex("(^\\s+|\\s+\$|\\s+(?=\\s))") private val suspiciousContentKey = ConfigKey.ShowSuspiciousContent(false)
private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash") private val tagsMap = SuspendLazy(::fetchTags)
private val suspiciousContentKey = ConfigKey.ShowSuspiciousContent(false)
private val nextPages = MutableIntObjectMap<MutableIntLongMap>() override val isAuthorized: Boolean
get() {
override val filterCapabilities: MangaListFilterCapabilities val authorized = isAuthorized(DOMAIN_UNAUTHORIZED)
get() = MangaListFilterCapabilities( if (authorized) {
isMultipleTagsSupported = true, if (!isAuthorized(DOMAIN_AUTHORIZED)) {
isTagsExclusionSupported = true, context.cookieJar.copyCookies(
isSearchSupported = true, DOMAIN_UNAUTHORIZED,
isSearchWithFiltersSupported = true, DOMAIN_AUTHORIZED,
isAuthorSearchSupported = true, authCookies,
) )
context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "yay=louder")
override suspend fun isAuthorized(): Boolean = checkAuth() }
return true
init { }
context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2") return false
context.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2") }
paginator.firstPage = 0
searchPaginator.firstPage = 0 init {
} context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2")
context.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
override suspend fun getFilterOptions() = MangaListFilterOptions( paginator.firstPage = 0
availableTags = mapTags(), }
availableContentTypes = EnumSet.of(
ContentType.DOUJINSHI, override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
ContentType.MANGA, val next = nextPages.get(page, 0L)
ContentType.ARTIST_CG,
ContentType.GAME_CG, if (page > 0 && next == 0L) {
ContentType.COMICS, assert(false) { "Page timestamp not found" }
ContentType.IMAGE_SET, return emptyList()
ContentType.OTHER, }
),
availableLocales = setOf( var search = ""
Locale.JAPANESE,
Locale.ENGLISH, val url = buildString {
Locale.CHINESE, append("https://")
Locale("nl"), append(domain)
Locale.FRENCH, append("/?next=")
Locale.GERMAN, append(next)
Locale("hu"), when (filter) {
Locale.ITALIAN,
Locale("kr"), is MangaListFilter.Search -> {
Locale("pl"), search += filter.query.urlEncoded()
Locale("pt"), append("&f_search=")
Locale("ru"), append(search.trim().replace(' ', '+'))
Locale("es"), }
Locale("th"),
Locale("vi"), is MangaListFilter.Advanced -> {
),
) filter.toSearchQuery()?.let { sq ->
append("&f_search=")
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> { append(sq.urlEncoded())
return getListPage(page, order, filter, updateDm = false) }
}
val catsOn = filter.tags.mapNotNullToSet { it.key.toIntOrNull() }
private suspend fun getListPage( val catsOff = filter.tagsExclude.mapNotNullToSet { it.key.toIntOrNull() }
page: Int, if (catsOff.size >= 10) {
order: SortOrder, return emptyList()
filter: MangaListFilter, }
updateDm: Boolean, var fCats = catsOn.fold(0, Int::or)
): List<Manga> { if (fCats != 0) {
val next = synchronized(nextPages) { fCats = 1023 - fCats
nextPages[filter.hashCode()]?.getOrDefault(page, 0L) ?: 0L }
} fCats = catsOff.fold(fCats, Int::or)
if (page > 0 && next == 0L) { if (fCats != 0) {
assert(false) { "Page timestamp not found" } append("&f_cats=")
return emptyList() append(fCats)
} }
}
val url = urlBuilder()
url.addEncodedQueryParameter("next", next.toString()) null -> {}
url.addQueryParameter("f_search", filter.toSearchQuery()) }
// by unknown reason cookie "sl=dm_2" is ignored, so, we should request it again
val fCats = filter.types.toFCats() if (updateDm) {
if (fCats != 0) { append("&inline_set=dm_e")
url.addEncodedQueryParameter("f_cats", (1023 - fCats).toString()) }
} append("&advsearch=1")
if (updateDm) { if (config[suspiciousContentKey]) {
// by unknown reason cookie "sl=dm_2" is ignored, so, we should request it again append("&f_sh=on")
url.addQueryParameter("inline_set", "dm_e") }
} }
url.addQueryParameter("advsearch", "1")
if (config[suspiciousContentKey]) { val body = webClient.httpGet(url).parseHtml().body()
url.addQueryParameter("f_sh", "on") val root = body.selectFirst("table.itg")
} ?.selectFirst("tbody")
val body = webClient.httpGet(url.build()).parseHtml().body() ?: if (updateDm) {
val root = body.selectFirst("table.itg")?.selectFirst("tbody") body.parseFailed("Cannot find root")
if (root == null) { } else {
if (updateDm) { updateDm = true
if (body.getElementsContainingText("No hits found").isNotEmpty()) { return getListPage(page, filter)
return emptyList() }
} else { updateDm = false
body.parseFailed("Cannot find root") nextPages[page + 1] = getNextTimestamp(body)
} return root.children().mapNotNull { tr ->
} else { if (tr.childrenSize() != 2) return@mapNotNull null
return getListPage(page, order, filter, updateDm = true) val (td1, td2) = tr.children()
} val gLink = td2.selectFirstOrThrow("div.glink")
} val a = gLink.parents().select("a").first() ?: gLink.parseFailed("link not found")
val nextTimestamp = getNextTimestamp(body) val href = a.attrAsRelativeUrl("href")
synchronized(nextPages) { val tagsDiv = gLink.nextElementSibling() ?: gLink.parseFailed("tags div not found")
nextPages.getOrPut(filter.hashCode()) { val mainTag = td2.selectFirst("div.cn")?.let { div ->
MutableIntLongMap() MangaTag(
}.put(page + 1, nextTimestamp) title = div.text().toTitleCase(),
} key = tagIdByClass(div.classNames()) ?: return@let null,
source = source,
return root.children().mapNotNull { tr -> )
if (tr.childrenSize() != 2) return@mapNotNull null }
val (td1, td2) = tr.children() Manga(
val gLink = td2.selectFirstOrThrow("div.glink") id = generateUid(href),
val a = gLink.parents().select("a").first() ?: gLink.parseFailed("link not found") title = gLink.text().cleanupTitle(),
val href = a.attrAsRelativeUrl("href") altTitle = null,
val tagsDiv = gLink.nextElementSibling() ?: gLink.parseFailed("tags div not found") url = href,
val rawTitle = gLink.text() publicUrl = a.absUrl("href"),
val author = tagsDiv.getElementsContainingOwnText("artist:").first() rating = td2.selectFirst("div.ir")?.parseRating() ?: RATING_UNKNOWN,
?.nextElementSibling()?.textOrNull() isNsfw = true,
Manga( coverUrl = td1.selectFirst("img")?.absUrl("src").orEmpty(),
id = generateUid(href), tags = setOfNotNull(mainTag),
title = rawTitle.cleanupTitle(), state = null,
altTitles = emptySet(), author = tagsDiv.getElementsContainingOwnText("artist:").first()
url = href, ?.nextElementSibling()?.text(),
publicUrl = a.absUrl("href"), source = source,
rating = td2.selectFirst("div.ir")?.parseRating() ?: RATING_UNKNOWN, )
contentRating = ContentRating.ADULT, }
coverUrl = td1.selectFirst("img")?.attrAsAbsoluteUrlOrNull("src"), }
tags = tagsDiv.parseTags(),
state = when { override suspend fun getDetails(manga: Manga): Manga {
rawTitle.contains("(ongoing)", ignoreCase = true) -> MangaState.ONGOING val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
else -> null val root = doc.body().selectFirstOrThrow("div.gm")
}, val cover = root.getElementById("gd1")?.children()?.first()
authors = setOfNotNull(author), val title = root.getElementById("gd2")
source = source, val tagList = root.getElementById("taglist")
) val tabs = doc.body().selectFirst("table.ptt")?.selectFirst("tr")
} val lang = root.getElementById("gd3")
} ?.selectFirst("tr:contains(Language)")
?.selectFirst(".gdt2")?.ownTextOrNull()
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val tagMap = tagsMap.get()
val root = doc.body().selectFirstOrThrow("div.gm") val tags = ArraySet<MangaTag>()
val cover = root.getElementById("gd1")?.children()?.first() tagList?.selectFirst("tr:contains(female:)")?.select("a")?.mapNotNullTo(tags) { tagMap[it.text()] }
val title = root.getElementById("gd2") tagList?.selectFirst("tr:contains(male:)")?.select("a")?.mapNotNullTo(tags) { tagMap[it.text()] }
val tagList = root.getElementById("taglist")
val tabs = doc.body().selectFirst("table.ptt")?.selectFirst("tr") return manga.copy(
val gd3 = root.getElementById("gd3") title = title?.getElementById("gn")?.text()?.cleanupTitle() ?: manga.title,
val lang = gd3 altTitle = title?.getElementById("gj")?.text()?.cleanupTitle() ?: manga.altTitle,
?.selectFirst("tr:contains(Language)") publicUrl = doc.baseUri().ifEmpty { manga.publicUrl },
?.selectFirst(".gdt2")?.ownTextOrNull() rating = root.getElementById("rating_label")?.text()
val uploadDate = gd3 ?.substringAfterLast(' ')
?.selectFirst("tr:contains(Posted)") ?.toFloatOrNull()
?.selectFirst(".gdt2")?.ownTextOrNull() ?.div(5f) ?: manga.rating,
.let { SimpleDateFormat("yyyy-MM-dd HH:mm", sourceLocale).parseSafe(it) } largeCoverUrl = cover?.styleValueOrNull("background")?.cssUrl(),
val uploader = gd3 tags = tags,
?.getElementsByAttributeValueContaining("href", "/uploader/") description = tagList?.select("tr")?.joinToString("<br>") { tr ->
?.firstOrNull() val (tc, td) = tr.children()
?.ownTextOrNull() val subTags = td.select("a").joinToString { it.html() }
val tags = tagList?.parseTags().orEmpty() "<b>${tc.html()}</b> $subTags"
},
return manga.copy( chapters = tabs?.select("a")?.findLast { a ->
title = title?.getElementById("gn")?.text()?.cleanupTitle() ?: manga.title, a.text().toIntOrNull() != null
altTitles = setOfNotNull(title?.getElementById("gj")?.text()?.cleanupTitle()?.nullIfEmpty()), }?.let { a ->
publicUrl = doc.baseUri().ifEmpty { manga.publicUrl }, val count = a.text().toInt()
rating = root.getElementById("rating_label")?.text() val chapters = ChaptersListBuilder(count)
?.substringAfterLast(' ') for (i in 1..count) {
?.toFloatOrNull() val url = "${manga.url}?p=${i - 1}"
?.div(5f) ?: manga.rating, chapters += MangaChapter(
largeCoverUrl = cover?.styleValueOrNull("background")?.cssUrl(), id = generateUid(url),
tags = manga.tags + tags, name = "${manga.title} #$i",
description = tagList?.select("tr")?.joinToString("<br>") { tr -> number = i,
val (tc, td) = tr.children() url = url,
val subTags = td.select("a").joinToString { it.html() } uploadDate = 0L,
"<b>${tc.html()}</b> $subTags" source = source,
}, scanlator = null,
chapters = tabs?.select("a")?.findLast { a -> branch = lang,
a.text().toIntOrNull() != null )
}?.let { a -> }
val count = a.text().toInt() chapters.toList()
val chapters = ChaptersListBuilder(count) },
for (i in 1..count) { )
val url = "${manga.url}?p=${i - 1}" }
chapters += MangaChapter(
id = generateUid(url), override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
title = null, val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
number = i.toFloat(), val root = doc.body().requireElementById("gdt")
volume = 0, return root.select("a").map { a ->
url = url, val url = a.attrAsRelativeUrl("href")
uploadDate = uploadDate, MangaPage(
source = source, id = generateUid(url),
scanlator = uploader, url = url,
branch = lang, preview = null,
) source = source,
} )
chapters.toList() }
}, }
)
} override suspend fun getPageUrl(page: MangaPage): String {
val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { return doc.body().requireElementById("img").attrAsAbsoluteUrl("src")
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() }
val root = doc.body().requireElementById("gdt")
return root.select("a").map { a -> private val tags =
val url = a.attrAsRelativeUrl("href") "ahegao,anal,angel,apron,bandages,bbw,bdsm,beauty mark,big areolae,big ass,big breasts,big clit,big lips," +
MangaPage( "big nipples,bikini,blackmail,bloomers,blowjob,bodysuit,bondage,breast expansion,bukkake,bunny girl,business suit," +
id = generateUid(url), "catgirl,centaur,cheating,chinese dress,christmas,collar,corset,cosplaying,cowgirl,crossdressing,cunnilingus," +
url = url, "dark skin,daughter,deepthroat,defloration,demon girl,double penetration,dougi,dragon,drunk,elf,exhibitionism,farting," +
preview = a.children().firstOrNull()?.extractPreview(), "females only,femdom,filming,fingering,fishnets,footjob,fox girl,furry,futanari,garter belt,ghost,giantess," +
source = source, "glasses,gloves,goblin,gothic lolita,growth,guro,gyaru,hair buns,hairy,hairy armpits,handjob,harem,hidden sex," +
) "horns,huge breasts,humiliation,impregnation,incest,inverted nipples,kemonomimi,kimono,kissing,lactation," +
} "latex,leg lock,leotard,lingerie,lizard girl,maid,masked face,masturbation,midget,miko,milf,mind break," +
} "mind control,monster girl,mother,muscle,nakadashi,netorare,nose hook,nun,nurse,oil,paizuri,panda girl," +
"pantyhose,piercing,pixie cut,policewoman,ponytail,pregnant,rape,rimjob,robot,scat,lolicon,schoolgirl uniform," +
override suspend fun getPageUrl(page: MangaPage): String { "sex toys,shemale,sister,small breasts,smell,sole dickgirl,sole female,squirting,stockings,sundress,sweating," +
val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml() "swimsuit,swinging,tail,tall girl,teacher,tentacles,thigh high boots,tomboy,transformation,twins,twintails," +
return doc.body().requireElementById("img").attrAsAbsoluteUrl("src") "unusual pupils,urination,vore,vtuber,widow,wings,witch,wolf girl,x-ray,yuri,zombie,sole male,males only,yaoi," +
} "tomgirl,tall man,oni,shotacon,prostate massage,policeman,males only,huge penis,fox boy,feminization,dog boy,dickgirl on male,big penis"
@Suppress("SpellCheckingInspection") override suspend fun getAvailableTags(): Set<MangaTag> {
private val tags: String return tagsMap.get().values.toSet()
get() = "ahegao,anal,angel,apron,bandages,bbw,bdsm,beauty mark,big areolae,big ass,big breasts,big clit,big lips," + }
"big nipples,bikini,blackmail,bloomers,blowjob,bodysuit,bondage,breast expansion,bukkake,bunny girl,business suit," +
"catgirl,centaur,cheating,chinese dress,christmas,collar,corset,cosplaying,cowgirl,crossdressing,cunnilingus," + private suspend fun fetchTags(): Map<String, MangaTag> {
"dark skin,daughter,deepthroat,defloration,demon girl,double penetration,dougi,dragon,drunk,elf,exhibitionism,farting," + val tagMap = ArrayMap<String, MangaTag>()
"females only,femdom,filming,fingering,fishnets,footjob,fox girl,furry,futanari,garter belt,ghost,giantess," + val tagElements = tags.split(",")
"glasses,gloves,goblin,gothic lolita,growth,guro,gyaru,hair buns,hairy,hairy armpits,handjob,harem,hidden sex," + for (el in tagElements) {
"horns,huge breasts,humiliation,impregnation,incest,inverted nipples,kemonomimi,kimono,kissing,lactation," + if (el.isEmpty()) continue
"latex,leg lock,leotard,lingerie,lizard girl,maid,masked face,masturbation,midget,miko,milf,mind break," + tagMap[el] = MangaTag(
"mind control,monster girl,mother,muscle,nakadashi,netorare,nose hook,nun,nurse,oil,paizuri,panda girl," + title = el.toTitleCase(Locale.ENGLISH),
"pantyhose,piercing,pixie cut,policewoman,ponytail,pregnant,rape,rimjob,robot,scat,lolicon,schoolgirl uniform," + key = el,
"sex toys,shemale,sister,small breasts,smell,sole dickgirl,sole female,squirting,stockings,sundress,sweating," + source = source,
"swimsuit,swinging,tail,tall girl,teacher,tentacles,thigh high boots,tomboy,transformation,twins,twintails," + )
"unusual pupils,urination,vore,vtuber,widow,wings,witch,wolf girl,x-ray,yuri,zombie,sole male,males only,yaoi," + }
"tomgirl,tall man,oni,shotacon,prostate massage,policeman,males only,huge penis,fox boy,feminization,dog boy,dickgirl on male,big penis"
val doc = webClient.httpGet("https://${domain}").parseHtml()
private fun mapTags(): Set<MangaTag> { val root = doc.body().requireElementById("searchbox").selectFirstOrThrow("table")
val tagElements = tags.split(",") root.select("div.cs").mapNotNullToSet { div ->
val result = ArraySet<MangaTag>(tagElements.size) val id = div.id().substringAfterLast('_').toIntOrNull() ?: return@mapNotNullToSet null
for (tag in tagElements) { val name = div.text().toTitleCase(Locale.ENGLISH)
val el = tag.trim() tagMap[name] = MangaTag(
if (el.isEmpty()) continue title = "Kind: $name",
result += MangaTag( key = id.toString(),
title = el.toTitleCase(Locale.ENGLISH), source = source,
key = el, )
source = source, }
) return tagMap
} }
return result
} override suspend fun getAvailableLocales(): Set<Locale> = setOf(
Locale.JAPANESE,
override fun intercept(chain: Interceptor.Chain): Response { Locale.ENGLISH,
val response = chain.proceed(chain.request()) Locale.CHINESE,
if (response.headersContentLength(BANNED_RESPONSE_LENGTH) <= BANNED_RESPONSE_LENGTH) { Locale("nl"),
val text = response.peekBody(BANNED_RESPONSE_LENGTH).use { it.string() } Locale.FRENCH,
if (text.contains("IP address has been temporarily banned", ignoreCase = true)) { Locale.GERMAN,
val hours = Regex("([0-9]+) hours?").find(text)?.groupValues?.getOrNull(1)?.toLongOrNull() ?: 0 Locale("hu"),
val minutes = Regex("([0-9]+) minutes?").find(text)?.groupValues?.getOrNull(1)?.toLongOrNull() ?: 0 Locale.ITALIAN,
val seconds = Regex("([0-9]+) seconds?").find(text)?.groupValues?.getOrNull(1)?.toLongOrNull() ?: 0 Locale("kr"),
response.closeQuietly() Locale("pl"),
throw TooManyRequestExceptions( Locale("pt"),
url = response.request.url.toString(), Locale("ru"),
retryAfter = TimeUnit.HOURS.toMillis(hours) Locale("es"),
+ TimeUnit.MINUTES.toMillis(minutes) Locale("th"),
+ TimeUnit.SECONDS.toMillis(seconds), Locale("vi"),
) )
}
} private fun Locale.toLanguagePath() = when (language) {
val imageRect = response.request.url.fragment?.split(',') else -> getDisplayLanguage(Locale.ENGLISH).lowercase()
if (imageRect != null && imageRect.size == 4) { }
// rect: top,left,right,bottom
return context.redrawImageResponse(response) { bitmap -> override suspend fun getUsername(): String {
val srcRect = Rect( val doc = webClient.httpGet("https://forums.$DOMAIN_UNAUTHORIZED/").parseHtml().body()
left = imageRect[0].toInt(), val username = doc.getElementById("userlinks")
top = imageRect[1].toInt(), ?.getElementsByAttributeValueContaining("href", "showuser=")
right = imageRect[2].toInt(), ?.firstOrNull()
bottom = imageRect[3].toInt(), ?.ownText()
) ?: if (doc.getElementById("userlinksguest") != null) {
val dstRect = Rect(0, 0, srcRect.width, srcRect.height) throw AuthRequiredException(source)
val result = context.createBitmap(dstRect.width, dstRect.height) } else {
result.drawBitmap(bitmap, srcRect, dstRect) doc.parseFailed()
result }
} return username
} }
return response
} override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
private fun Locale.toLanguagePath() = when (language) { keys.add(suspiciousContentKey)
else -> getDisplayLanguage(Locale.ENGLISH).lowercase() }
}
private fun isAuthorized(domain: String): Boolean {
override suspend fun getUsername(): String { val cookies = context.cookieJar.getCookies(domain).mapToSet { x -> x.name }
val doc = webClient.httpGet("https://forums.$DOMAIN_UNAUTHORIZED/").parseHtml().body() return authCookies.all { it in cookies }
val username = doc.getElementById("userlinks") }
?.getElementsByAttributeValueContaining("href", "showuser=")
?.firstOrNull() private fun Element.parseRating(): Float {
?.ownText() return runCatching {
?: if (doc.getElementById("userlinksguest") != null) { val style = requireNotNull(attr("style"))
throw AuthRequiredException(source) val (v1, v2) = ratingPattern.find(style)!!.destructured
} else { var p1 = v1.dropLast(2).toInt()
doc.parseFailed() val p2 = v2.dropLast(2).toInt()
} if (p2 != -1) {
return username p1 += 8
} }
(80 - p1) / 80f
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) { }.getOrDefault(RATING_UNKNOWN)
super.onCreateConfig(keys) }
keys.add(userAgentKey)
keys.add(suspiciousContentKey) private fun String.cleanupTitle(): String {
} val result = StringBuilder(length)
var skip = false
override suspend fun getRelatedManga(seed: Manga): List<Manga> { for (c in this) {
val query = seed.title when {
return getListPage( c == '[' -> skip = true
page = 0, c == ']' -> skip = false
order = defaultSortOrder, c.isWhitespace() && result.isEmpty() -> continue
filter = MangaListFilter(query = query), !skip -> result.append(c)
) }
} }
while (result.lastOrNull()?.isWhitespace() == true) {
private fun isAuthorized(domain: String): Boolean { result.deleteCharAt(result.lastIndex)
val cookies = context.cookieJar.getCookies(domain).mapToSet { x -> x.name } }
return authCookies.all { it in cookies } return result.toString()
} }
private fun Element.parseRating(): Float { private fun String.cssUrl(): String? {
return runCatching { val fromIndex = indexOf("url(")
val style = requireNotNull(attr("style")) if (fromIndex == -1) {
val (v1, v2) = ratingPattern.findAll(style).toList() return null
var p1 = v1.groupValues.first().dropLast(2).toInt() }
val p2 = v2.groupValues.first().dropLast(2).toInt() val toIndex = indexOf(')', startIndex = fromIndex)
if (p2 != -1) { return if (toIndex == -1) {
p1 += 8 null
} } else {
(80 - p1) / 80f substring(fromIndex + 4, toIndex).trim()
}.getOrDefault(RATING_UNKNOWN) }
} }
private fun String.cleanupTitle(): String { private fun tagIdByClass(classNames: Collection<String>): String? {
return replace(titleCleanupPattern, "") val className = classNames.find { x -> x.startsWith("ct") } ?: return null
.replace(spacesCleanupPattern, "") val num = className.drop(2).toIntOrNull(16) ?: return null
} return 2.0.pow(num).toInt().toString()
}
private fun Element.parseTags(): Set<MangaTag> {
private fun getNextTimestamp(root: Element): Long {
fun Element.parseTag() = textOrNull()?.let { return root.getElementById("unext")
MangaTag(title = it.toTitleCase(Locale.ENGLISH), key = it, source = source) ?.attrAsAbsoluteUrlOrNull("href")
} ?.toHttpUrlOrNull()
?.queryParameter("next")
val result = ArraySet<MangaTag>() ?.toLongOrNull() ?: 1
for (prefix in TAG_PREFIXES) { }
getElementsByAttributeValueStarting("id", "ta_$prefix").mapNotNullTo(result, Element::parseTag)
getElementsByAttributeValueStarting("title", prefix).mapNotNullTo(result, Element::parseTag) private fun MangaListFilter.Advanced.toSearchQuery(): String? {
} val joiner = StringUtil.StringJoiner(" ")
return result for (tag in tags) {
} if (tag.key.isNumeric()) {
continue
private fun Element.extractPreview(): String? { }
val bg = backgroundOrNull() ?: return null joiner.add("tag:\"")
return buildString { joiner.append(tag.key)
append(bg.url) joiner.append("\"$")
append('#') }
// rect: left,top,right,bottom for (tag in tagsExclude) {
append(bg.left) if (tag.key.isNumeric()) {
append(',') continue
append(bg.top) }
append(',') joiner.add("-tag:\"")
append(bg.right) joiner.append(tag.key)
append(',') joiner.append("\"$")
append(bg.bottom) }
} locale?.let { lc ->
} joiner.add("language:\"")
joiner.append(lc.toLanguagePath())
private fun getNextTimestamp(root: Element): Long { joiner.append("\"$")
return root.getElementById("unext") }
?.attrAsAbsoluteUrlOrNull("href") return joiner.complete().takeUnless { it.isEmpty() }
?.toHttpUrlOrNull() }
?.queryParameter("next")
?.toLongOrNull() ?: 1
}
private fun MangaListFilter.toSearchQuery(): String? {
if (isEmpty()) {
return null
}
val joiner = StringUtil.StringJoiner(" ")
if (!query.isNullOrEmpty()) {
joiner.add(query)
}
for (tag in tags) {
if (tag.key.isNumeric()) {
continue
}
joiner.add("tag:\"")
joiner.append(tag.key)
joiner.append("\"$")
}
for (tag in tagsExclude) {
if (tag.key.isNumeric()) {
continue
}
joiner.add("-tag:\"")
joiner.append(tag.key)
joiner.append("\"$")
}
locale?.let { lc ->
joiner.add("language:\"")
joiner.append(lc.toLanguagePath())
joiner.append("\"$")
}
if (!author.isNullOrEmpty()) {
joiner.add("artist:\"")
joiner.append(author)
joiner.append("\"$")
}
return joiner.complete().nullIfEmpty()
}
private fun Collection<ContentType>.toFCats(): Int = fold(0) { acc, ct ->
val cat: Int = when (ct) {
ContentType.DOUJINSHI -> 2
ContentType.MANGA -> 4
ContentType.ARTIST_CG -> 8
ContentType.GAME_CG -> 16
ContentType.COMICS -> 512
ContentType.IMAGE_SET -> 32
else -> 449 // 1 or 64 or 128 or 256
}
acc or cat
}
private fun checkAuth(): Boolean {
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
}
} }

@ -11,89 +11,68 @@ import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey 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.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.security.MessageDigest import java.security.MessageDigest
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.EnumSet import java.util.*
import java.util.LinkedList
import java.util.Locale
import kotlin.math.min import kotlin.math.min
@OptIn(ExperimentalUnsignedTypes::class) @OptIn(ExperimentalUnsignedTypes::class)
@MangaSourceParser("HITOMILA", "Hitomi.La", type = ContentType.HENTAI) @MangaSourceParser("HITOMILA", "Hitomi.La", type = ContentType.HENTAI)
internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser(context, MangaParserSource.HITOMILA) { class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.HITOMILA) {
override val configKeyDomain = ConfigKey.Domain("hitomi.la") override val configKeyDomain = ConfigKey.Domain("hitomi.la")
private val cdnDomain = "gold-usergeneratedcontent.net" private val ltnBaseUrl get() = "https://${getDomain("ltn")}"
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
private val ltnBaseUrl get() = "https://ltn.$cdnDomain"
override val availableSortOrders: Set<SortOrder> = EnumSet.of( override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST, SortOrder.NEWEST,
SortOrder.POPULARITY_TODAY, SortOrder.POPULARITY,
SortOrder.POPULARITY_WEEK,
SortOrder.POPULARITY_MONTH,
SortOrder.POPULARITY_YEAR,
) )
private val localeMap: Map<Locale, String> = mapOf( private val localeMap: Map<Locale, String> = mapOf(
Locale.forLanguageTag("id") to "indonesian", Locale("id") to "indonesian",
Locale.forLanguageTag("jv") to "javanese", Locale("jv") to "javanese",
Locale.forLanguageTag("ca") to "catalan", Locale("ca") to "catalan",
Locale.forLanguageTag("ceb") to "cebuano", Locale("ceb") to "cebuano",
Locale.forLanguageTag("cs") to "czech", Locale("cs") to "czech",
Locale.forLanguageTag("da") to "danish", Locale("da") to "danish",
Locale.forLanguageTag("de") to "german", Locale("de") to "german",
Locale.forLanguageTag("et") to "estonian", Locale("et") to "estonian",
Locale.ENGLISH to "english", Locale.ENGLISH to "english",
Locale.forLanguageTag("es") to "spanish", Locale("es") to "spanish",
Locale.forLanguageTag("eo") to "esperanto", Locale("eo") to "esperanto",
Locale.forLanguageTag("fr") to "french", Locale("fr") to "french",
Locale.forLanguageTag("it") to "italian", Locale("it") to "italian",
Locale.forLanguageTag("hi") to "hindi", Locale("hi") to "hindi",
Locale.forLanguageTag("hu") to "hungarian", Locale("hu") to "hungarian",
Locale.forLanguageTag("pl") to "polish", Locale("pl") to "polish",
Locale.forLanguageTag("pt") to "portuguese", Locale("pt") to "portuguese",
Locale.forLanguageTag("vi") to "vietnamese", Locale("vi") to "vietnamese",
Locale.forLanguageTag("tr") to "turkish", Locale("tr") to "turkish",
Locale.forLanguageTag("ru") to "russian", Locale("ru") to "russian",
Locale.forLanguageTag("uk") to "ukrainian", Locale("uk") to "ukrainian",
Locale.forLanguageTag("ar") to "arabic", Locale("ar") to "arabic",
Locale.KOREAN to "korean", Locale.KOREAN to "korean",
Locale.CHINESE to "chinese", Locale.CHINESE to "chinese",
Locale.JAPANESE to "japanese", Locale.JAPANESE to "japanese",
) )
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities(
isMultipleTagsSupported = true,
isSearchSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = fetchAvailableTags(),
availableLocales = localeMap.keys,
)
private fun Locale?.getSiteLang(): String = when (this) { private fun Locale?.getSiteLang(): String = when (this) {
null -> "all" null -> "all"
else -> localeMap[this] ?: "all" else -> localeMap[this] ?: "all"
} }
private suspend fun fetchAvailableTags(): Set<MangaTag> = coroutineScope { override suspend fun getAvailableLocales(): Set<Locale> = localeMap.keys
override suspend fun getAvailableTags(): Set<MangaTag> = coroutineScope {
('a'..'z').map { alphabet -> ('a'..'z').map { alphabet ->
async { async {
val doc = webClient.httpGet("https://$domain/alltags-$alphabet.html").parseHtml() val doc = webClient.httpGet("https://$domain/alltags-$alphabet.html").parseHtml()
@ -125,12 +104,14 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
private var cachedSearchIds: List<Int> = emptyList() private var cachedSearchIds: List<Int> = emptyList()
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> = when { override suspend fun getList(
filter.query.isNullOrEmpty() -> { offset: Int,
filter: MangaListFilter?,
): List<Manga> = when (filter) {
is MangaListFilter.Advanced -> {
if (filter.tags.isEmpty()) { if (filter.tags.isEmpty()) {
when (order) { when (filter.sortOrder) {
SortOrder.POPULARITY_TODAY -> { SortOrder.POPULARITY -> {
getGalleryIDsFromNozomi( getGalleryIDsFromNozomi(
"popular", "popular",
"today", "today",
@ -139,33 +120,6 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
) )
} }
SortOrder.POPULARITY_WEEK -> {
getGalleryIDsFromNozomi(
"popular",
"week",
filter.locale.getSiteLang(),
offset.nextOffsetRange(),
)
}
SortOrder.POPULARITY_MONTH -> {
getGalleryIDsFromNozomi(
"popular",
"month",
filter.locale.getSiteLang(),
offset.nextOffsetRange(),
)
}
SortOrder.POPULARITY_YEAR -> {
getGalleryIDsFromNozomi(
"popular",
"year",
filter.locale.getSiteLang(),
offset.nextOffsetRange(),
)
}
else -> { else -> {
getGalleryIDsFromNozomi(null, "index", filter.locale.getSiteLang(), offset.nextOffsetRange()) getGalleryIDsFromNozomi(null, "index", filter.locale.getSiteLang(), offset.nextOffsetRange())
} }
@ -175,7 +129,7 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
cachedSearchIds = cachedSearchIds =
hitomiSearch( hitomiSearch(
filter.tags.joinToString(" ") { it.key }, filter.tags.joinToString(" ") { it.key },
order, filter.sortOrder == SortOrder.POPULARITY,
filter.locale.getSiteLang(), filter.locale.getSiteLang(),
).toList() ).toList()
} }
@ -183,12 +137,14 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
} }
} }
else -> { is MangaListFilter.Search -> {
if (offset == 0) { if (offset == 0) {
cachedSearchIds = hitomiSearch(filter.query, order).toList() cachedSearchIds = hitomiSearch(filter.query, filter.sortOrder == SortOrder.POPULARITY).toList()
} }
cachedSearchIds.subList(offset, min(offset + 25, cachedSearchIds.size)) cachedSearchIds.subList(offset, min(offset + 25, cachedSearchIds.size))
} }
else -> getGalleryIDsFromNozomi(null, "popular", "all", offset.nextOffsetRange())
}.toMangaList() }.toMangaList()
private fun Int.nextOffsetRange(): LongRange { private fun Int.nextOffsetRange(): LongRange {
@ -198,7 +154,7 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
private suspend fun hitomiSearch( private suspend fun hitomiSearch(
query: String, query: String,
sortByPopularity: SortOrder = SortOrder.UPDATED, sortByPopularity: Boolean = false,
language: String = "all", language: String = "all",
): Set<Int> = ): Set<Int> =
coroutineScope { coroutineScope {
@ -206,7 +162,7 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
.trim() .trim()
.replace(Regex("""^\?"""), "") .replace(Regex("""^\?"""), "")
.lowercase() .lowercase()
.splitByWhitespace() .split(Regex("\\s+"))
.map { .map {
it.replace('_', ' ') it.replace('_', ' ')
} }
@ -239,11 +195,7 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
} }
val results = when { val results = when {
sortByPopularity == SortOrder.UPDATED -> getGalleryIDsFromNozomi(null, "index", language) sortByPopularity -> getGalleryIDsFromNozomi(null, "popular", language)
sortByPopularity == SortOrder.POPULARITY_TODAY -> getGalleryIDsFromNozomi("popular", "today", language)
sortByPopularity == SortOrder.POPULARITY_WEEK -> getGalleryIDsFromNozomi("popular", "week", language)
sortByPopularity == SortOrder.POPULARITY_MONTH -> getGalleryIDsFromNozomi("popular", "month", language)
sortByPopularity == SortOrder.POPULARITY_YEAR -> getGalleryIDsFromNozomi("popular", "year", language)
positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(null, "index", language) positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(null, "index", language)
else -> emptySet() else -> emptySet()
}.toMutableSet() }.toMutableSet()
@ -426,7 +378,7 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
return nozomi return nozomi
} }
private val galleriesIndexVersion = suspendLazy { private val galleriesIndexVersion = SuspendLazy {
webClient.httpGet("$ltnBaseUrl/galleriesindex/version?_=${System.currentTimeMillis()}").parseRaw() webClient.httpGet("$ltnBaseUrl/galleriesindex/version?_=${System.currentTimeMillis()}").parseRaw()
} }
@ -449,7 +401,9 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
for (i in 0.until(numberOfKeys)) { for (i in 0.until(numberOfKeys)) {
val keySize = buffer.int val keySize = buffer.int
check(keySize in 1..32) { "Invalid key size $keySize" } if (keySize == 0 || keySize > 32) {
throw Exception("fatal: !keySize || keySize > 32")
}
keys.add(uData.sliceArray(buffer.position().until(buffer.position() + keySize))) keys.add(uData.sliceArray(buffer.position().until(buffer.position() + keySize)))
buffer.position(buffer.position() + keySize) buffer.position(buffer.position() + keySize)
@ -519,18 +473,19 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
title = doc.selectFirstOrThrow("h1").text(), title = doc.selectFirstOrThrow("h1").text(),
url = id.toString(), url = id.toString(),
coverUrl = coverUrl =
"https:" + "https:" +
doc.selectFirstOrThrow("picture > img") doc.selectFirstOrThrow("picture > source")
.attr("data-src"), .attr("data-srcset")
.substringBefore(" "),
publicUrl = publicUrl =
doc.selectFirstOrThrow("h1 > a") doc.selectFirstOrThrow("h1 > a")
.attrAsRelativeUrl("href") .attrAsRelativeUrl("href")
.toAbsoluteUrl(domain), .toAbsoluteUrl(domain),
authors = emptySet(), author = null,
tags = emptySet(), tags = emptySet(),
contentRating = ContentRating.ADULT, isNsfw = true,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
altTitles = emptySet(), altTitle = null,
state = null, state = null,
source = source, source = source,
) )
@ -544,53 +499,51 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
.parseRaw() .parseRaw()
.substringAfter("var galleryinfo = ") .substringAfter("var galleryinfo = ")
.let(::JSONObject) .let(::JSONObject)
val author =
json.optJSONArray("artists")
?.mapJSON { it.getString("artist").toCamelCase() }
?.joinToString()
return manga.copy( return manga.copy(
title = json.getString("title"), title = json.getString("title"),
largeCoverUrl = largeCoverUrl =
json.getJSONArray("files").getJSONObject(0).let { json.getJSONArray("files").getJSONObject(0).let {
val hash = it.getString("hash") val hash = it.getString("hash")
val imageId = imageIdFromHash(hash) val commonId = commonImageId()
val subDomain = 'a' + subdomainOffset(imageId) val imageId = imageIdFromHash(hash)
val subDomain = 'a' + subdomainOffset(imageId)
"https://${subDomain}tn.$cdnDomain/webpbigtn/${thumbPathFromHash(hash)}/$hash.webp"
}, "https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp"
authors = setOfNotNull(author), },
author =
json.optJSONArray("artists")
?.mapJSON { it.getString("artist").toCamelCase() }
?.joinToString(),
publicUrl = json.getString("galleryurl").toAbsoluteUrl(domain), publicUrl = json.getString("galleryurl").toAbsoluteUrl(domain),
tags = tags =
buildSet buildSet {
{ json.optJSONArray("characters")
json.optJSONArray("characters") ?.mapToTags("character")
?.mapToTags("character") ?.let(::addAll)
?.let(::addAll) json.optJSONArray("tags")
json.optJSONArray("tags") ?.mapToTags("tag")
?.mapToTags("tag") ?.let(::addAll)
?.let(::addAll) json.optJSONArray("artists")
json.optJSONArray("artists") ?.mapToTags("artist")
?.mapToTags("artist") ?.let(::addAll)
?.let(::addAll) json.optJSONArray("parodys")
json.optJSONArray("parodys") ?.mapToTags("parody")
?.mapToTags("parody") ?.let(::addAll)
?.let(::addAll) json.optJSONArray("groups")
json.optJSONArray("groups") ?.mapToTags("group")
?.mapToTags("group") ?.let(::addAll)
?.let(::addAll) },
},
chapters = listOf( chapters = listOf(
MangaChapter( MangaChapter(
id = generateUid(manga.url), id = generateUid(manga.url),
url = manga.url, url = manga.url,
title = json.getStringOrNull("title"), name = json.getString("title"),
scanlator = json.getString("type").toTitleCase(), scanlator = json.getString("type").toTitleCase(),
number = 1f, number = 1,
volume = 0,
branch = json.getString("language_localname"), branch = json.getString("language_localname"),
source = source, source = source,
uploadDate = dateFormat.parseSafe(json.getString("date").substringBeforeLast("-")), uploadDate = dateFormat.tryParse(json.getString("date").substringBeforeLast("-")),
), ),
), ),
) )
@ -603,15 +556,15 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
mapJSON { mapJSON {
MangaTag( MangaTag(
title = title =
it.getString(key).toCamelCase().let { title -> it.getString(key).toCamelCase().let { title ->
if (it.getStringOrNull("female")?.toIntOrNull() == 1) { if (it.getStringOrNull("female")?.toIntOrNull() == 1) {
"$title" "$title"
} else if (it.getStringOrNull("male")?.toIntOrNull() == 1) { } else if (it.getStringOrNull("male")?.toIntOrNull() == 1) {
"$title" "$title"
} else { } else {
title title
} }
}, },
key = it.getString("url").tagUrlToTag(), key = it.getString("url").tagUrlToTag(),
source = source, source = source,
).let(tags::add) ).let(tags::add)
@ -657,27 +610,27 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
val hash = image.getString("hash") val hash = image.getString("hash")
val commonId = commonImageId() val commonId = commonImageId()
val imageId = imageIdFromHash(hash) val imageId = imageIdFromHash(hash)
val subDomain = subdomainOffset(imageId) + 1 val subDomain = 'a' + subdomainOffset(imageId)
val thumbSubdomain = 'a' + subdomainOffset(imageId)
MangaPage( MangaPage(
id = generateUid(hash), id = generateUid(hash),
url = "https://a${subDomain}.$cdnDomain/$commonId$imageId/$hash.avif", url = "https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp",
preview = "https://${thumbSubdomain}tn.$cdnDomain/webpsmallsmalltn/${thumbPathFromHash(hash)}/$hash.webp", preview = "https://${getDomain("${subDomain}tn")}/webpsmalltn/${thumbPathFromHash(hash)}/$hash.webp",
source = source, source = source,
) )
} }
} }
// / ---> // / --->
private var scriptLastRetrieval: Long = -1L private var scriptLastRetrieval: Long? = null
private val mutex = Mutex() private val mutex = Mutex()
private var subdomainOffsetDefault = 0 private var subdomainOffsetDefault = 0
private val subdomainOffsetMap = mutableMapOf<Int, Int>() private val subdomainOffsetMap = mutableMapOf<Int, Int>()
private var commonImageId = "" private var commonImageId = ""
private suspend fun refreshScript() = mutex.withLock { private suspend fun refreshScript() = mutex.withLock {
if (scriptLastRetrieval == -1L || (scriptLastRetrieval + 60000) < System.currentTimeMillis()) { if (scriptLastRetrieval == null || (scriptLastRetrieval!! + 60000) < System.currentTimeMillis()) {
val ggScript = webClient.httpGet("$ltnBaseUrl/gg.js?_=${System.currentTimeMillis()}").parseRaw() val ggScript = webClient.httpGet("$ltnBaseUrl/gg.js?_=${System.currentTimeMillis()}").parseRaw()
subdomainOffsetDefault = Regex("var o = (\\d)").find(ggScript)!!.groupValues[1].toInt() subdomainOffsetDefault = Regex("var o = (\\d)").find(ggScript)!!.groupValues[1].toInt()
@ -718,51 +671,31 @@ internal class HitomiLaParser(context: MangaLoaderContext) : AbstractMangaParser
return hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1") return hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1")
} }
// rewrite_tn_paths <-- common.js private suspend fun subdomainFromURL(url: String, base: String? = null): String {
private suspend fun rewriteTnPaths(html: String): String { var retval = "b"
val thumbUrlRegex = Regex(
"""(?<protocol>//)(?<host>[a-z0-9.-]+\.(?:hitomi\.la|${Regex.escape(cdnDomain)}))/(?<pathAfterHost>(?:avif|webp)?(?:small)?(?:big|small|medium)tn/[0-9a-f]/[0-9a-f]{2}/[0-9a-f]{64}\.(?:webp|avif|gif|png|jpe?g))""",
)
var resultHtml = html if (!base.isNullOrBlank())
thumbUrlRegex.findAll(html).forEach { matchResult -> retval = base
val originalUrl = matchResult.value
val groups = matchResult.groups
val pathAfterHost = groups["pathAfterHost"]?.value ?: return@forEach val regex = Regex("""/[0-9a-f]{61}([0-9a-f]{2})([0-9a-f])""")
val newTnSubdomain = subdomainFromURL(originalUrl, "tn") val hashMatch = regex.find(url) ?: return "a"
val correctedUrl = "${groups["protocol"]!!.value}$newTnSubdomain.$cdnDomain/$pathAfterHost" val imageId = hashMatch.groupValues.let { it[2] + it[1] }.toIntOrNull(16)
if (originalUrl != correctedUrl) { if (imageId != null) {
resultHtml = resultHtml.replace(originalUrl, correctedUrl) retval = ('a' + subdomainOffset(imageId)).toString() + retval
}
} }
return resultHtml
}
private suspend fun subdomainFromURL(url: String, base: String?): String {
val resultSubdomain = base ?: "b"
// This regex extracts the last 3 hex characters from the hash in the URL return retval
// The hash is 64 characters, so we look for the 61st character onward }
val hashRegex = Regex("""/([0-9a-f]{61}[0-9a-f]{3})[./]""")
val fullHashMatch = hashRegex.find(url)
?: // If no hash is found, default to "a" + base (typically "atn")
return "a$resultSubdomain"
val fullHash = fullHashMatch.groupValues[1]
val lastThreeChars = fullHash.takeLast(3)
val lastDigit = lastThreeChars.last()
val lastTwoDigits = lastThreeChars.take(2)
val imageId = "$lastDigit$lastTwoDigits".toIntOrNull(16) // rewrite_tn_paths <-- common.js
private suspend fun rewriteTnPaths(html: String): String {
val tnRegex = Regex("""//tn\.hitomi\.la/[^/]+/[0-9a-f]/[0-9a-f]{2}/[0-9a-f]{64}""")
val url = tnRegex.find(html)?.value ?: return html
val newSubdomain = subdomainFromURL(url, "tn")
val newUrl = url.replace(Regex("""//..?\.hitomi\.la/"""), "//${getDomain(newSubdomain)}/")
return if (imageId != null) { return html.replace(tnRegex, newUrl)
('a' + subdomainOffset(imageId)).toString() + resultSubdomain
} else {
"a$resultSubdomain"
}
} }
private fun String.toTagTitle(): String { private fun String.toTagTitle(): String {

@ -1,140 +0,0 @@
package org.koitharu.kotatsu.parsers.site.all
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.generateUid
import org.koitharu.kotatsu.parsers.util.parseHtml
import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
import org.koitharu.kotatsu.parsers.util.parseSafe
import java.text.SimpleDateFormat
import java.util.Locale
@MangaSourceParser("HOLOEARTH", "HoloEarth")
internal class HoloEarthParser(context: MangaLoaderContext) :
PagedMangaParser(context, MangaParserSource.HOLOEARTH, 3) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("holoearth.com")
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val availableSortOrders: Set<SortOrder> = setOf(SortOrder.NEWEST)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities(
isSearchSupported = false,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableLocales = setOf(
Locale("en"),
Locale.JAPANESE,
Locale("id"),
),
)
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val url = buildString {
append("https://$domain")
filter.locale?.let {
append(
when (it) {
Locale("en") -> "/en"
Locale.JAPANESE -> ""
Locale("id") -> "/id"
else -> "" // default
}
)
}
append("/alt/holonometria/manga")
}
val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().selectFirstOrThrow(".manga__list")
val mangaList = root.select("li .manga__item-inner")
if (mangaList.isEmpty()) return emptyList()
return mangaList.mapNotNull { li ->
val coverUrl = li.getElementsByTag("img").attr("src")
val title = li.getElementsByClass("manga__title").text()
val altTitle = li.getElementsByClass("manga__copy").text()
val description = li.getElementsByClass("manga__caption").text()
val url = li.getElementsByTag("a").attr("href")
Manga(
id = generateUid(url),
title = title,
altTitles = setOf(altTitle),
url = url,
publicUrl = url,
rating = RATING_UNKNOWN,
contentRating = null,
coverUrl = coverUrl,
tags = emptySet(),
state = null,
authors = emptySet(),
source = source,
description = description,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url).parseHtml()
val dateFormat = SimpleDateFormat("yyyy.MM.dd", Locale.US)
val root = doc.body().selectFirstOrThrow(".manga-detail__wrapper")
val coverUrl = root.selectFirstOrThrow(".manga-detail__thumb img").attr("src")
val chapters = root.select(".manga-detail__list-item")
val mangaChapters = chapters.mapIndexed { index, li ->
val url = li.selectFirstOrThrow(".manga-detail__list-link").attr("href")
val title = li.selectFirstOrThrow(".manga-detail__list-title").text()
val dateStr = li.selectFirstOrThrow(".manga-detail__list-date").text()
val uploadDate = dateFormat.parseSafe(dateStr) ?: 0L
val scanlator = root.selectFirst(".manga-detail__person")?.text()
MangaChapter(
id = generateUid(url),
title = title,
number = index + 1f,
volume = 0,
url = url,
scanlator = scanlator,
uploadDate = uploadDate,
branch = null,
source = source,
)
}
return manga.copy(
coverUrl = coverUrl,
chapters = mangaChapters,
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = webClient.httpGet(chapter.url).parseHtml()
val imageList = doc.body().selectFirstOrThrow(".manga-detail__swiper-wrapper")
val images = imageList.select(".manga-detail__swiper-slide").reversed()
return images.mapNotNull { page ->
val img = page.selectFirst("img") ?: return@mapNotNull null
val src = img.attr("src")
MangaPage(
id = generateUid(src),
url = src,
preview = src,
source = source,
)
}
}
}

@ -7,93 +7,39 @@ import kotlinx.coroutines.coroutineScope
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.util.* import java.util.*
@MangaSourceParser("IMHENTAI", "ImHentai", type = ContentType.HENTAI) @MangaSourceParser("IMHENTAI", "ImHentai", type = ContentType.HENTAI)
internal class ImHentai(context: MangaLoaderContext) : internal class ImHentai(context: MangaLoaderContext) :
PagedMangaParser(context, MangaParserSource.IMHENTAI, pageSize = 20) { PagedMangaParser(context, MangaSource.IMHENTAI, pageSize = 20) {
override val availableSortOrders: Set<SortOrder> = override val availableSortOrders: Set<SortOrder> =
EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.RATING) EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.RATING)
override val configKeyDomain = ConfigKey.Domain("imhentai.xxx") override val configKeyDomain = ConfigKey.Domain("imhentai.xxx")
override val filterCapabilities: MangaListFilterCapabilities override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
get() = MangaListFilterCapabilities(
isMultipleTagsSupported = true,
isSearchSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = fetchAvailableTags(),
availableLocales = setOf(
Locale.ENGLISH,
Locale.JAPANESE,
Locale("es"),
Locale.FRENCH,
Locale("kr"),
Locale.GERMAN,
Locale("ru"),
),
availableContentTypes = EnumSet.of(
ContentType.MANGA,
ContentType.DOUJINSHI,
ContentType.COMICS,
ContentType.IMAGE_SET,
ContentType.ARTIST_CG,
ContentType.GAME_CG,
),
)
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override suspend fun getListPage(
page: Int,
order: SortOrder,
filter: MangaListFilter,
): List<Manga> {
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/search/?page=") append("/search/?page=")
append(page.toString()) append(page.toString())
when { when (filter) {
!filter.query.isNullOrEmpty() -> { is MangaListFilter.Search -> {
append("&key=") append("&key=")
append(filter.query.urlEncoded()) append(filter.query.urlEncoded())
} }
else -> { is MangaListFilter.Advanced -> {
if (filter.tags.isNotEmpty()) { if (filter.tags.isNotEmpty()) {
append("&key=") append("&key=")
filter.tags.joinTo(this, separator = ",") { it.key } append(filter.tags.joinToString(separator = ",") { it.key })
}
var types = "&m=1&d=1&w=1&i=1&a=1&g=1"
if (filter.types.isNotEmpty()) {
types = "&m=0&d=0&w=0&i=0&a=0&g=0"
filter.types.forEach {
when (it) {
ContentType.MANGA -> types = types.replace("&m=0", "&m=1")
ContentType.DOUJINSHI -> types = types.replace("&d=0", "&d=1")
ContentType.COMICS -> types = types.replace("&w=0", "&w=1")
ContentType.IMAGE_SET -> types = types.replace("&i=0", "&i=1")
ContentType.ARTIST_CG -> types = types.replace("&a=0", "&a=1")
ContentType.GAME_CG -> types = types.replace("&g=0", "&g=1")
else -> {}
}
}
} }
append(types)
var lang = "&en=1&jp=1&es=1&fr=1&kr=1&de=1&ru=1" var lang = "&en=1&jp=1&es=1&fr=1&kr=1&de=1&ru=1"
filter.locale?.let { filter.locale?.let {
@ -102,13 +48,17 @@ internal class ImHentai(context: MangaLoaderContext) :
} }
append(lang) append(lang)
when (order) { when (filter.sortOrder) {
SortOrder.UPDATED -> append("&lt=1&pp=0") SortOrder.UPDATED -> append("&lt=1&pp=0")
SortOrder.POPULARITY -> append("&lt=0&pp=1") SortOrder.POPULARITY -> append("&lt=0&pp=1")
SortOrder.RATING -> append("&lt=0&pp=0") SortOrder.RATING -> append("&lt=0&pp=0")
else -> append("&lt=1&pp=0") else -> append("&lt=1&pp=0")
} }
} }
null -> {
append("&lt=1&pp=0")
}
} }
} }
@ -120,30 +70,30 @@ internal class ImHentai(context: MangaLoaderContext) :
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.toAbsoluteUrl(domain), publicUrl = href.toAbsoluteUrl(domain),
coverUrl = a.selectFirst("img")?.src(), coverUrl = a.selectFirst("img")?.src().orEmpty(),
title = div.selectFirst(".caption")?.text().orEmpty(), title = div.selectFirst(".caption")?.text().orEmpty(),
altTitles = emptySet(), altTitle = null,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
tags = emptySet(), tags = emptySet(),
authors = emptySet(), author = null,
state = null, state = null,
source = source, source = source,
contentRating = if (isNsfwSource) ContentRating.ADULT else null, isNsfw = isNsfwSource,
) )
} }
} }
//Tags are deliberately reduced because there are too many and this slows down the application. //Tags are deliberately reduced because there are too many and this slows down the application.
//only the most popular ones are taken. //only the most popular ones are taken.
private suspend fun fetchAvailableTags(): Set<MangaTag> { override suspend fun getAvailableTags(): Set<MangaTag> {
return coroutineScope { return coroutineScope {
(1..3).map { page -> (1..3).map { page ->
async { fetchTagsPage(page) } async { getTags(page) }
} }
}.awaitAll().flattenTo(ArraySet(360)) }.awaitAll().flattenTo(ArraySet(360))
} }
private suspend fun fetchTagsPage(page: Int): Set<MangaTag> { private suspend fun getTags(page: Int): Set<MangaTag> {
val url = "https://$domain/tags/popular/?page=$page" val url = "https://$domain/tags/popular/?page=$page"
val root = webClient.httpGet(url).parseHtml() val root = webClient.httpGet(url).parseHtml()
return root.parseTags() return root.parseTags()
@ -158,30 +108,33 @@ internal class ImHentai(context: MangaLoaderContext) :
) )
} }
override suspend fun getAvailableLocales(): Set<Locale> = setOf(
Locale.ENGLISH, Locale.JAPANESE, Locale("es"), Locale.FRENCH, Locale("kr"), Locale.GERMAN, Locale("ru"),
)
override suspend fun getDetails(manga: Manga): Manga = coroutineScope { override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val fullUrl = manga.url.toAbsoluteUrl(domain) val fullUrl = manga.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val author = doc.selectFirst("li:contains(Artists) a.tag")?.ownTextOrNull()
manga.copy( manga.copy(
tags = doc.body().select("li:contains(Tags) a.tag").mapNotNullToSet { tags = doc.body().select("li:contains(Tags) a.tag").mapNotNullToSet {
val href = it.attr("href").substringAfterLast("tag/").substringBeforeLast('/') val href = it.attr("href").substringAfterLast("tag/").substringBeforeLast('/')
val name = it.html().substringBeforeLast("<span")
MangaTag( MangaTag(
key = href, key = href,
title = it.ownText().toTitleCase(sourceLocale), title = name,
source = source, source = source,
) )
}, },
authors = setOfNotNull(author), author = doc.selectFirst("li:contains(Artists) a.tag")?.html()?.substringBefore("<span"),
chapters = listOf( chapters = listOf(
MangaChapter( MangaChapter(
id = manga.id, id = manga.id,
title = null, name = manga.title,
number = 1f, number = 1,
volume = 0,
url = manga.url, url = manga.url,
scanlator = null, scanlator = null,
uploadDate = 0, uploadDate = 0,
branch = doc.selectFirst("li:contains(Language) a.tag")?.ownTextOrNull()?.toTitleCase(sourceLocale), branch = doc.selectFirst("li:contains(Language) a.tag")?.html()?.substringBeforeLast("<span"),
source = source, source = source,
), ),
), ),
@ -198,15 +151,15 @@ internal class ImHentai(context: MangaLoaderContext) :
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.toAbsoluteUrl(domain), publicUrl = href.toAbsoluteUrl(domain),
coverUrl = a.selectFirst("img")?.src(), coverUrl = a.selectFirst("img")?.src().orEmpty(),
title = div.selectFirst(".caption")?.text().orEmpty(), title = div.selectFirst(".caption")?.text().orEmpty(),
altTitles = emptySet(), altTitle = null,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
tags = emptySet(), tags = emptySet(),
authors = emptySet(), author = null,
state = null, state = null,
source = source, source = source,
contentRating = null, isNsfw = false,
) )
} }
} }
@ -214,28 +167,20 @@ internal class ImHentai(context: MangaLoaderContext) :
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val totalPages = doc.selectFirstOrThrow(".pages").text().replace("Pages: ", "").toInt() val totalPages = doc.selectFirstOrThrow(".pages").text().replace("Pages: ", "").toInt() + 1
val baseImg = doc.requireElementById("append_thumbs").selectFirstOrThrow("img") val domainImg = doc.requireElementById("append_thumbs").selectFirstOrThrow("img").src()?.replace("1t.jpg", "")
val baseUrl = baseImg.selectFirstParentOrThrow("a").attrAsRelativeUrl("href").replace("/1/", "/\$/")
val baseThumbUrl = baseImg.src()?.replace("/1t.", "/\$t.")
val pages = ArrayList<MangaPage>(totalPages) val pages = ArrayList<MangaPage>(totalPages)
repeat(totalPages) { i -> for (i in 1 until totalPages) {
val url = baseUrl.replace("\$", (i + 1).toString()) val url = "$domainImg$i.jpg"
pages.add( pages.add(
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = baseThumbUrl?.replace("\$", (i + 1).toString()), preview = null,
source = source, source = source,
), ),
) )
} }
return pages return pages
} }
override suspend fun getPageUrl(page: MangaPage): String {
val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
val img = doc.body().requireElementById("gimg")
return img.requireSrc()
}
} }

@ -1,421 +0,0 @@
package org.koitharu.kotatsu.parsers.site.all
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.json.JSONObject
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
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.*
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNullToSet
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.net.HttpURLConnection
import java.text.SimpleDateFormat
import java.util.*
import org.koitharu.kotatsu.parsers.Broken
@Broken("Need to fix getPages, most manga don't have chapter images due to faulty fetch logic")
@MangaSourceParser("KOHARU", "Schale.network", type = ContentType.HENTAI)
internal class Koharu(context: MangaLoaderContext) :
PagedMangaParser(context, MangaParserSource.KOHARU, 24) {
override val configKeyDomain = ConfigKey.Domain("niyaniya.moe")
private val apiSuffix = "api.schale.network"
override val userAgentKey = ConfigKey.UserAgent(
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.46 Mobile Safari/537.36",
)
private val authorsIds = suspendLazy { fetchAuthorsIds() }
private val preferredImageResolutionKey = ConfigKey.PreferredImageServer(
presetValues = mapOf(
"0" to "Lowest Quality",
"780" to "Low Quality (780px)",
"980" to "Medium Quality (980px)",
"1280" to "High Quality (1280px)",
"1600" to "Highest Quality (1600px)",
),
defaultValue = "1280",
)
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
keys.add(preferredImageResolutionKey)
}
override fun getRequestHeaders() = super.getRequestHeaders().newBuilder()
.add("referer", "https://$domain/")
.add("origin", "https://$domain")
.build()
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST,
SortOrder.POPULARITY,
SortOrder.POPULARITY_TODAY,
SortOrder.POPULARITY_WEEK,
SortOrder.ALPHABETICAL,
SortOrder.ALPHABETICAL_DESC,
SortOrder.RATING,
)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities(
isMultipleTagsSupported = true,
isSearchSupported = true,
isAuthorSearchSupported = true,
isSearchWithFiltersSupported = true,
isTagsExclusionSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = fetchTags(namespace = 0),
)
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val baseUrl = "https://$apiSuffix/books"
val url = buildString {
append(baseUrl)
val terms: MutableList<String> = mutableListOf()
val includedTags: MutableList<String> = mutableListOf()
val excludedTags: MutableList<String> = mutableListOf()
if (!filter.query.isNullOrEmpty() && filter.query.startsWith("id:")) {
val ipk = filter.query.removePrefix("id:")
val response = webClient.httpGet("$baseUrl/detail/$ipk").parseJson()
return listOf(parseMangaDetail(response))
}
val sortValue = when (order) {
SortOrder.POPULARITY, SortOrder.POPULARITY_TODAY -> "8"
SortOrder.POPULARITY_WEEK -> "9"
SortOrder.ALPHABETICAL -> "2"
SortOrder.ALPHABETICAL_DESC -> "2"
SortOrder.RATING -> "3"
SortOrder.NEWEST -> "4"
else -> "4"
}
append("?sort=").append(sortValue)
if (!filter.query.isNullOrEmpty()) {
terms.add("title:\"${filter.query.urlEncoded()}\"")
}
if (!filter.author.isNullOrEmpty()) {
val authors = authorsIds.getOrDefault(emptyMap())
val authorId = authors[filter.author.lowercase()]
if (authorId != null) {
includedTags.add(authorId)
} else {
terms.add("artist:\"${filter.author.urlEncoded()}\"")
}
}
filter.tags.forEach { tag ->
if (tag.key.startsWith("-")) {
excludedTags.add(tag.key.substring(1))
} else {
includedTags.add(tag.key)
}
}
if (excludedTags.isNotEmpty()) {
append("&exclude=").append(excludedTags.joinToString(","))
append("&e=1")
}
if (includedTags.isNotEmpty()) {
append("&include=").append(includedTags.joinToString(","))
append("&i=1")
}
append("&page=").append(page)
if (terms.isNotEmpty()) {
append("&s=").append(terms.joinToString(" ").urlEncoded())
}
}
val json = webClient.httpGet(url).parseJson()
json.getStringOrNull("error")?.let {
throw ParseException(it, url)
}
json.getStringOrNull("message")?.let {
throw ParseException(it, url)
}
return parseMangaList(json)
}
private fun parseMangaList(json: JSONObject): List<Manga> {
val entries = json.optJSONArray("entries") ?: return emptyList()
val results = ArrayList<Manga>(entries.length())
for (i in 0 until entries.length()) {
val entry = entries.getJSONObject(i)
val id = entry.getLong("id")
val key = entry.getString("key")
val url = "$id/$key"
results.add(
Manga(
id = generateUid(id),
url = url,
publicUrl = "https://$domain/g/$url",
title = entry.getString("title"),
altTitles = emptySet(),
authors = emptySet(),
tags = emptySet(),
rating = RATING_UNKNOWN,
state = null,
coverUrl = entry.getJSONObject("thumbnail").getString("path"),
contentRating = ContentRating.ADULT,
source = source,
),
)
}
return results
}
private fun parseMangaDetail(json: JSONObject): Manga {
val data = json.getJSONObject("data")
val id = data.getLong("id")
val key = data.getString("key")
val url = "$id/$key"
var author: String? = null
val tags = data.optJSONArray("tags")
if (tags != null) {
for (i in 0 until tags.length()) {
val tag = tags.getJSONObject(i)
if (tag.getInt("namespace") == 1) {
author = tag.getString("name")
break
}
}
}
return Manga(
id = generateUid(id),
url = url,
publicUrl = "https://$domain/g/$url",
title = data.getString("title"),
altTitles = emptySet(),
authors = setOfNotNull(author),
tags = emptySet(),
rating = RATING_UNKNOWN,
state = null,
coverUrl = data.getJSONObject("thumbnails").getJSONObject("main").getString("path"),
contentRating = ContentRating.ADULT,
source = source,
)
}
override suspend fun getDetails(manga: Manga): Manga {
val url = manga.url
val response = webClient.httpGet("https://$apiSuffix/books/detail/$url").parseJson()
val id = response.getLong("id")
val key = response.getString("key")
val mangaUrl = "$id/$key"
val tagsList = mutableSetOf<MangaTag>()
var author: String? = null
val tags = response.optJSONArray("tags")
if (tags != null) {
for (i in 0 until tags.length()) {
val tag = tags.getJSONObject(i)
if (tag.has("namespace")) {
val namespace = tag.getInt("namespace")
val tagName = tag.getString("name")
when (namespace) {
1 -> {
author = tagName
}
0, 3, 8, 9, 10, 12 -> {
tagsList.add(
MangaTag(
key = tagName,
title = tagName.toTitleCase(sourceLocale),
source = source,
),
)
}
}
} else {
val tagName = tag.getString("name")
tagsList.add(
MangaTag(
key = tagName,
title = tagName.toTitleCase(sourceLocale),
source = source,
),
)
}
}
}
val description = buildString {
val created = response.getLongOrDefault("created_at", 0L)
if (created > 0) {
append("<b>Posted:</b> ").append(SimpleDateFormat("yyyy-MM-dd", Locale.US).format(created)).append("\n")
}
val thumbnails = response.getJSONObject("thumbnails")
val pageCount = thumbnails.optJSONArray("entries")?.length() ?: 0
append("<b>Pages:</b> ").append(pageCount)
}
val thumbnails = response.getJSONObject("thumbnails")
val base = thumbnails.getString("base")
val mainPath = thumbnails.getJSONObject("main").getString("path")
val coverUrl = base + mainPath
return Manga(
id = generateUid(id),
url = mangaUrl,
publicUrl = "https://$domain/g/$mangaUrl",
title = response.getString("title"),
altTitles = emptySet(),
authors = setOfNotNull(author),
tags = tagsList,
rating = RATING_UNKNOWN,
state = MangaState.FINISHED,
description = description,
coverUrl = coverUrl,
contentRating = ContentRating.ADULT,
source = source,
chapters = listOf(
MangaChapter(
id = generateUid("$mangaUrl/chapter"),
title = null,
number = 1f,
url = mangaUrl,
scanlator = null,
uploadDate = response.getLongOrDefault("created_at", 0L),
branch = null,
source = source,
volume = 0,
),
),
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val mangaUrl = chapter.url
val parts = mangaUrl.split('/')
if (parts.size < 2) {
throw ParseException("Invalid URL", mangaUrl)
}
val id = parts[0]
val key = parts[1]
val clearance = getClearance(chapter.publicUrl())
val dataUrl = "https://$apiSuffix/books/detail/$id/$key?crt=$clearance"
val data = try {
webClient.httpPost(
url = dataUrl.toHttpUrl(),
form = emptyMap(),
extraHeaders = getRequestHeaders(),
).parseJson().getJSONObject("data")
} catch (e: HttpStatusException) {
if (e.statusCode == HttpURLConnection.HTTP_FORBIDDEN) {
// Token may be invalid or expired
// WebView should be closed after receiving Token
context.requestBrowserAction(this, chapter.publicUrl())
}
throw e
}
val preferredRes = config[preferredImageResolutionKey] ?: "1280"
val resolutionOrder = when (preferredRes) {
"1600" -> listOf("1600", "1280", "0", "980", "780")
"1280" -> listOf("1280", "1600", "0", "980", "780")
"980" -> listOf("980", "1280", "0", "1600", "780")
"780" -> listOf("780", "980", "0", "1280", "1600")
else -> listOf("0", "1600", "1280", "980", "780")
}
var selectedImageId: Int? = null
var selectedPublicKey: String? = null
var selectedQuality = "0"
for (res in resolutionOrder) {
if (data.has(res) && !data.isNull(res)) {
val resData = data.getJSONObject(res)
if (resData.has("id") && resData.has("key")) {
selectedImageId = resData.getInt("id")
selectedPublicKey = resData.getString("key")
selectedQuality = res
break
}
}
}
if (selectedImageId == null || selectedPublicKey == null) {
throw ParseException("Cant find image data", dataUrl)
}
val imagesResponse = webClient.httpGet(
"https://$apiSuffix/books/data/$id/$key/$selectedImageId/$selectedPublicKey/$selectedQuality?crt=$clearance",
).parseJson()
val base = imagesResponse.getString("base")
val entries = imagesResponse.getJSONArray("entries")
val pages = ArrayList<MangaPage>(entries.length())
for (i in 0 until entries.length()) {
val imagePath = entries.getJSONObject(i).getString("path")
val fullImageUrl = "$base$imagePath"
pages.add(
MangaPage(
id = generateUid(fullImageUrl),
url = fullImageUrl,
preview = null,
source = source,
),
)
}
return pages
}
private suspend fun fetchTags(namespace: Int): Set<MangaTag> =
webClient.httpGet("https://$apiSuffix/books/tags/filters").parseJsonArray().mapJSONNotNullToSet {
if (it.getIntOrDefault("namespace", 0) != namespace) {
null
} else {
MangaTag(
title = it.getStringOrNull("name")
?.toTitleCase(sourceLocale) ?: return@mapJSONNotNullToSet null,
key = it.getStringOrNull("id") ?: return@mapJSONNotNullToSet null,
source = source,
)
}
}
private suspend fun fetchAuthorsIds(): Map<String, String> = fetchTags(namespace = 1)
.associate { it.title.lowercase() to it.key }
private suspend fun getClearance(chapterUrl: String): String = WebViewHelper(context)
.getLocalStorageValue(domain, "clearance")?.removeSurrounding('"')?.nullIfEmpty()
?: context.requestBrowserAction(this, chapterUrl)
private fun MangaChapter.publicUrl() = "https://$domain/g/$url/read/1"
}

@ -2,13 +2,14 @@ package org.koitharu.kotatsu.parsers.site.all
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import okhttp3.Headers
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
@ -20,17 +21,10 @@ import javax.crypto.spec.SecretKeySpec
internal abstract class LineWebtoonsParser( internal abstract class LineWebtoonsParser(
context: MangaLoaderContext, context: MangaLoaderContext,
source: MangaParserSource, source: MangaSource,
) : AbstractMangaParser(context, source) { ) : MangaParser(context, source) {
override val filterCapabilities: MangaListFilterCapabilities override val isMultipleTagsSupported = false
get() = MangaListFilterCapabilities(
isSearchSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = fetchAvailableTags(),
)
private val signer by lazy { private val signer by lazy {
WebtoonsUrlSigner("gUtPzJFZch4ZyAGviiyH94P99lQ3pFdRTwpJWDlSGFfwgpr6ses5ALOxWHOIT7R1") WebtoonsUrlSigner("gUtPzJFZch4ZyAGviiyH94P99lQ3pFdRTwpJWDlSGFfwgpr6ses5ALOxWHOIT7R1")
@ -54,13 +48,10 @@ internal abstract class LineWebtoonsParser(
SortOrder.RATING, SortOrder.RATING,
SortOrder.UPDATED, SortOrder.UPDATED,
) )
override val headers: Headers
override val userAgentKey = ConfigKey.UserAgent("nApps (Android 12;; linewebtoon; 3.1.0)") get() = Headers.Builder()
.add("User-Agent", "nApps (Android 12;; linewebtoon; 3.1.0)")
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) { .build()
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
return page.url.toAbsoluteUrl(staticDomain) return page.url.toAbsoluteUrl(staticDomain)
@ -86,7 +77,7 @@ internal abstract class LineWebtoonsParser(
val episodes = firstResult val episodes = firstResult
.getJSONObject("episodeList") .getJSONObject("episodeList")
.getJSONArray("episode") .getJSONArray("episode")
.asTypedList<JSONObject>() .toJSONList()
.toMutableList() .toMutableList()
while (episodes.count() < totalEpisodeCount) { while (episodes.count() < totalEpisodeCount) {
@ -94,7 +85,7 @@ internal abstract class LineWebtoonsParser(
url = "/lineWebtoon/webtoon/challengeEpisodeList.json?v=2&titleNo=$titleNo&startIndex=${episodes.count()}&pageSize=30", url = "/lineWebtoon/webtoon/challengeEpisodeList.json?v=2&titleNo=$titleNo&startIndex=${episodes.count()}&pageSize=30",
).getJSONObject("episodeList") ).getJSONObject("episodeList")
.getJSONArray("episode") .getJSONArray("episode")
.asTypedList<JSONObject>() .toJSONList()
episodes.addAll(page) episodes.addAll(page)
} }
@ -102,9 +93,8 @@ internal abstract class LineWebtoonsParser(
return episodes.mapChapters { i, jo -> return episodes.mapChapters { i, jo ->
MangaChapter( MangaChapter(
id = generateUid("$titleNo-$i"), id = generateUid("$titleNo-$i"),
title = jo.getStringOrNull("episodeTitle"), name = jo.getString("episodeTitle"),
number = jo.getInt("episodeSeq").toFloat(), number = jo.getInt("episodeSeq"),
volume = 0,
url = "$titleNo-${jo.get("episodeNo")}", url = "$titleNo-${jo.get("episodeNo")}",
uploadDate = jo.getLong("modifyYmdt"), uploadDate = jo.getLong("modifyYmdt"),
branch = null, branch = null,
@ -121,20 +111,18 @@ internal abstract class LineWebtoonsParser(
makeRequest("/lineWebtoon/webtoon/challengeTitleInfo.json?v=2&titleNo=${titleNo}") makeRequest("/lineWebtoon/webtoon/challengeTitleInfo.json?v=2&titleNo=${titleNo}")
.getJSONObject("titleInfo") .getJSONObject("titleInfo")
.let { jo -> .let { jo ->
val isNsfwSource = jo.getBooleanOrDefault("ageGradeNotice", isNsfwSource)
val author = jo.getStringOrNull("writingAuthorName")
Manga( Manga(
id = generateUid(titleNo), id = generateUid(titleNo),
title = jo.getString("title"), title = jo.getString("title"),
altTitles = emptySet(), altTitle = null,
url = "$titleNo", url = "$titleNo",
publicUrl = "https://$domain/$languageCode/canvas/a/list?title_no=${titleNo}", publicUrl = "https://$domain/$languageCode/canvas/a/list?title_no=${titleNo}",
rating = jo.getFloatOrDefault("starScoreAverage", -10f) / 10f, rating = jo.getFloatOrDefault("starScoreAverage", -10f) / 10f,
contentRating = if (isNsfwSource) ContentRating.ADULT else null, isNsfw = jo.getBooleanOrDefault("ageGradeNotice", isNsfwSource),
coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain), coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain),
largeCoverUrl = jo.getStringOrNull("thumbnailVertical")?.toAbsoluteUrl(staticDomain), largeCoverUrl = jo.getStringOrNull("thumbnailVertical")?.toAbsoluteUrl(staticDomain),
tags = setOf(parseTag(jo.getJSONObject("genreInfo"))), tags = setOf(parseTag(jo.getJSONObject("genreInfo"))),
authors = setOfNotNull(author), author = jo.getStringOrNull("writingAuthorName"),
description = jo.getString("synopsis"), description = jo.getString("synopsis"),
// I don't think the API provides this info // I don't think the API provides this info
state = null, state = null,
@ -144,82 +132,116 @@ internal abstract class LineWebtoonsParser(
} }
} }
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> { override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
val manga = when { val manga =
!filter.query.isNullOrEmpty() -> { when (filter) {
makeRequest("/lineWebtoon/webtoon/searchChallenge?query=${filter.query.urlEncoded()}&startIndex=${offset + 1}&pageSize=20") is MangaListFilter.Search -> {
.getJSONObject("challengeSearch") makeRequest("/lineWebtoon/webtoon/searchChallenge?query=${filter.query.urlEncoded()}&startIndex=${offset + 1}&pageSize=20")
.getJSONArray("titleList") .getJSONObject("challengeSearch")
.mapJSON { jo -> .getJSONArray("titleList")
val titleNo = jo.getLong("titleNo") .mapJSON { jo ->
val author = jo.getStringOrNull("writingAuthorName") val titleNo = jo.getLong("titleNo")
Manga( Manga(
id = generateUid(titleNo), id = generateUid(titleNo),
title = jo.getString("title"), title = jo.getString("title"),
altTitles = emptySet(), altTitle = null,
url = titleNo.toString(), url = titleNo.toString(),
publicUrl = "https://$domain/$languageCode/canvas/a/list?title_no=$titleNo", publicUrl = "https://$domain/$languageCode/canvas/a/list?title_no=$titleNo",
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
contentRating = if (isNsfwSource) ContentRating.ADULT else null, isNsfw = isNsfwSource,
coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain), coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain),
largeCoverUrl = null, largeCoverUrl = null,
tags = emptySet(), tags = emptySet(),
authors = setOfNotNull(author), author = jo.getStringOrNull("writingAuthorName"),
description = null, description = null,
state = null, state = null,
source = source, source = source,
) )
} }
} }
else -> { is MangaListFilter.Advanced -> {
val genre = filter.tags.oneOrThrowIfMany()?.key ?: "ALL"
val genre = filter.tags.oneOrThrowIfMany()?.key ?: "ALL" val sortOrderStr = when (filter.sortOrder) {
SortOrder.UPDATED -> "UPDATE"
SortOrder.POPULARITY -> "READ_COUNT"
SortOrder.RATING -> "LIKEIT"
else -> throw IllegalArgumentException("Unsupported sort order: ${filter.sortOrder}")
}
val sortOrderStr = when (order) { val result =
SortOrder.UPDATED -> "UPDATE" makeRequest("/lineWebtoon/webtoon/challengeGenreTitleList.json?genre=$genre&sortOrder=$sortOrderStr&startIndex=${offset + 1}&pageSize=20")
SortOrder.POPULARITY -> "READ_COUNT"
SortOrder.RATING -> "LIKEIT" val genres = result.getJSONObject("genreList")
else -> throw IllegalArgumentException("Unsupported sort order: $order") .getJSONArray("challengeGenres")
.mapJSON { jo -> parseTag(jo) }
.associateBy { tag -> tag.key }
result
.getJSONObject("titleList")
.getJSONArray("titles")
.mapJSON { jo ->
val titleNo = jo.getLong("titleNo")
Manga(
id = generateUid(titleNo),
title = jo.getString("title"),
altTitle = null,
url = titleNo.toString(),
publicUrl = "https://$domain/$languageCode/canvas/a/list?title_no=$titleNo",
rating = jo.getFloatOrDefault("starScoreAverage", -10f) / 10f,
isNsfw = jo.getBooleanOrDefault("ageGradeNotice", isNsfwSource),
coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain),
largeCoverUrl = jo.getStringOrNull("thumbnailVertical")?.toAbsoluteUrl(staticDomain),
tags = setOfNotNull(genres[jo.getString("representGenre")]),
author = jo.getStringOrNull("writingAuthorName"),
description = jo.getString("synopsis"),
// I don't think the API provides this info
state = null,
source = source,
)
}
} }
val result = null -> {
makeRequest("/lineWebtoon/webtoon/challengeGenreTitleList.json?genre=$genre&sortOrder=$sortOrderStr&startIndex=${offset + 1}&pageSize=20")
val result =
val genres = result.getJSONObject("genreList") makeRequest("/lineWebtoon/webtoon/challengeGenreTitleList.json?genre=ALL&sortOrder=UPDATE&startIndex=${offset + 1}&pageSize=20")
.getJSONArray("challengeGenres")
.mapJSON { jo -> parseTag(jo) } val genres = result.getJSONObject("genreList")
.associateBy { tag -> tag.key } .getJSONArray("challengeGenres")
.mapJSON { jo -> parseTag(jo) }
result .associateBy { tag -> tag.key }
.getJSONObject("titleList")
.getJSONArray("titles") result
.mapJSON { jo -> .getJSONObject("titleList")
val titleNo = jo.getLong("titleNo") .getJSONArray("titles")
val isNsfwSource = jo.getBooleanOrDefault("ageGradeNotice", isNsfwSource) .mapJSON { jo ->
val author = jo.getStringOrNull("writingAuthorName") val titleNo = jo.getLong("titleNo")
Manga( Manga(
id = generateUid(titleNo), id = generateUid(titleNo),
title = jo.getString("title"), title = jo.getString("title"),
altTitles = emptySet(), altTitle = null,
url = titleNo.toString(), url = titleNo.toString(),
publicUrl = "https://$domain/$languageCode/canvas/a/list?title_no=$titleNo", publicUrl = "https://$domain/$languageCode/canvas/a/list?title_no=$titleNo",
rating = jo.getFloatOrDefault("starScoreAverage", -10f) / 10f, rating = jo.getFloatOrDefault("starScoreAverage", -10f) / 10f,
contentRating = if (isNsfwSource) ContentRating.ADULT else null, isNsfw = jo.getBooleanOrDefault("ageGradeNotice", isNsfwSource),
coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain), coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain),
largeCoverUrl = jo.getStringOrNull("thumbnailVertical")?.toAbsoluteUrl(staticDomain), largeCoverUrl = jo.getStringOrNull("thumbnailVertical")?.toAbsoluteUrl(staticDomain),
tags = setOfNotNull(genres[jo.getString("representGenre")]), tags = setOfNotNull(genres[jo.getString("representGenre")]),
authors = setOfNotNull(author), author = jo.getStringOrNull("writingAuthorName"),
description = jo.getString("synopsis"), description = jo.getString("synopsis"),
// I don't think the API provides this info // I don't think the API provides this info
state = null, state = null,
source = source, source = source,
) )
} }
}
} }
}
return manga return manga
@ -241,11 +263,6 @@ internal abstract class LineWebtoonsParser(
} }
} }
override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? {
val titleNo = link.queryParameter("title_no") ?: return null
return resolver.resolveManga(this, url = titleNo)
}
private fun parseTag(jo: JSONObject): MangaTag { private fun parseTag(jo: JSONObject): MangaTag {
return MangaTag( return MangaTag(
title = jo.getString("name"), title = jo.getString("name"),
@ -254,7 +271,7 @@ internal abstract class LineWebtoonsParser(
) )
} }
private suspend fun fetchAvailableTags(): Set<MangaTag> { override suspend fun getAvailableTags(): Set<MangaTag> {
return makeRequest("/lineWebtoon/webtoon/challengeGenreList.json") return makeRequest("/lineWebtoon/webtoon/challengeGenreList.json")
.getJSONObject("genreList") .getJSONObject("genreList")
.getJSONArray("challengeGenres") .getJSONArray("challengeGenres")
@ -290,25 +307,25 @@ internal abstract class LineWebtoonsParser(
} }
@MangaSourceParser("LINEWEBTOONS_EN", "LineWebtoons English", "en", type = ContentType.MANGA) @MangaSourceParser("LINEWEBTOONS_EN", "LineWebtoons English", "en", type = ContentType.MANGA)
class English(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaParserSource.LINEWEBTOONS_EN) class English(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaSource.LINEWEBTOONS_EN)
@MangaSourceParser("LINEWEBTOONS_ZH", "LineWebtoons Chinese", "zh", type = ContentType.MANGA) @MangaSourceParser("LINEWEBTOONS_ZH", "LineWebtoons Chinese", "zh", type = ContentType.MANGA)
class Chinese(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaParserSource.LINEWEBTOONS_ZH) class Chinese(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaSource.LINEWEBTOONS_ZH)
@MangaSourceParser("LINEWEBTOONS_TH", "LineWebtoons Thai", "th", type = ContentType.MANGA) @MangaSourceParser("LINEWEBTOONS_TH", "LineWebtoons Thai", "th", type = ContentType.MANGA)
class Thai(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaParserSource.LINEWEBTOONS_TH) class Thai(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaSource.LINEWEBTOONS_TH)
@MangaSourceParser("LINEWEBTOONS_ID", "LineWebtoons Indonesian", "id", type = ContentType.MANGA) @MangaSourceParser("LINEWEBTOONS_ID", "LineWebtoons Indonesian", "id", type = ContentType.MANGA)
class Indonesian(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaParserSource.LINEWEBTOONS_ID) class Indonesian(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaSource.LINEWEBTOONS_ID)
@MangaSourceParser("LINEWEBTOONS_ES", "LineWebtoons Spanish", "es", type = ContentType.MANGA) @MangaSourceParser("LINEWEBTOONS_ES", "LineWebtoons Spanish", "es", type = ContentType.MANGA)
class Spanish(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaParserSource.LINEWEBTOONS_ES) class Spanish(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaSource.LINEWEBTOONS_ES)
@MangaSourceParser("LINEWEBTOONS_FR", "LineWebtoons French", "fr", type = ContentType.MANGA) @MangaSourceParser("LINEWEBTOONS_FR", "LineWebtoons French", "fr", type = ContentType.MANGA)
class French(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaParserSource.LINEWEBTOONS_FR) class French(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaSource.LINEWEBTOONS_FR)
@MangaSourceParser("LINEWEBTOONS_DE", "LineWebtoons German", "de", type = ContentType.MANGA) @MangaSourceParser("LINEWEBTOONS_DE", "LineWebtoons German", "de", type = ContentType.MANGA)
class German(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaParserSource.LINEWEBTOONS_DE) class German(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaSource.LINEWEBTOONS_DE)
private inner class WebtoonsUrlSigner(private val secret: String) { private inner class WebtoonsUrlSigner(private val secret: String) {

@ -4,21 +4,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import okhttp3.HttpUrl
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.FlexibleMangaParser
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* 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.model.search.QueryCriteria.*
import org.koitharu.kotatsu.parsers.model.search.SearchCapability
import org.koitharu.kotatsu.parsers.model.search.SearchableField
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.* import org.koitharu.kotatsu.parsers.util.json.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -30,264 +22,168 @@ private const val CHAPTERS_MAX_PAGE_SIZE = 500
private const val CHAPTERS_PARALLELISM = 3 private const val CHAPTERS_PARALLELISM = 3
private const val CHAPTERS_MAX_COUNT = 10_000 // strange api behavior, looks like a bug private const val CHAPTERS_MAX_COUNT = 10_000 // strange api behavior, looks like a bug
private const val LOCALE_FALLBACK = "en" private const val LOCALE_FALLBACK = "en"
private const val SERVER_DATA = "data"
private const val SERVER_DATA_SAVER = "data-saver"
@MangaSourceParser("MANGADEX", "MangaDex") @MangaSourceParser("MANGADEX", "MangaDex")
internal class MangaDexParser(context: MangaLoaderContext) : FlexibleMangaParser(context, MangaParserSource.MANGADEX) { internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.MANGADEX) {
override val configKeyDomain = ConfigKey.Domain("mangadex.org") override val configKeyDomain = ConfigKey.Domain("mangadex.org")
private val preferredServerKey = ConfigKey.PreferredImageServer( override val availableSortOrders: EnumSet<SortOrder> = EnumSet.allOf(SortOrder::class.java)
presetValues = mapOf(
SERVER_DATA to "Original quality",
SERVER_DATA_SAVER to "Compressed quality",
),
defaultValue = SERVER_DATA,
)
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) { override val availableContentRating: Set<ContentRating> = EnumSet.allOf(ContentRating::class.java)
super.onCreateConfig(keys)
keys.add(userAgentKey)
keys.add(preferredServerKey)
}
override val availableSortOrders: EnumSet<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.UPDATED_ASC,
SortOrder.POPULARITY,
SortOrder.POPULARITY_ASC,
SortOrder.RATING,
SortOrder.RATING_ASC,
SortOrder.NEWEST,
SortOrder.NEWEST_ASC,
SortOrder.ALPHABETICAL,
SortOrder.ALPHABETICAL_DESC,
SortOrder.ADDED,
SortOrder.ADDED_ASC,
SortOrder.RELEVANCE,
)
override val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = MangaSearchQueryCapabilities(
SearchCapability(
field = TAG,
criteriaTypes = setOf(Include::class, Exclude::class),
isMultiple = true,
),
SearchCapability(
field = TITLE_NAME,
criteriaTypes = setOf(Match::class),
isMultiple = false,
),
SearchCapability(
field = STATE,
criteriaTypes = setOf(Include::class),
isMultiple = true,
),
SearchCapability(
field = AUTHOR,
criteriaTypes = setOf(Include::class),
isMultiple = true,
),
SearchCapability(
field = CONTENT_TYPE,
criteriaTypes = setOf(Include::class),
isMultiple = true,
),
SearchCapability(
field = CONTENT_RATING,
criteriaTypes = setOf(Include::class),
isMultiple = true,
),
SearchCapability(
field = DEMOGRAPHIC,
criteriaTypes = setOf(Include::class),
isMultiple = true,
),
SearchCapability(
field = ORIGINAL_LANGUAGE,
criteriaTypes = setOf(Include::class),
isMultiple = true,
),
SearchCapability(
field = LANGUAGE,
criteriaTypes = setOf(Include::class),
isMultiple = true,
),
SearchCapability(
field = PUBLICATION_YEAR,
criteriaTypes = setOf(Match::class),
isMultiple = false,
),
)
override suspend fun getFilterOptions(): MangaListFilterOptions = coroutineScope {
val localesDeferred = async { fetchAvailableLocales() }
val tagsDeferred = async { fetchAvailableTags() }
MangaListFilterOptions(
availableTags = tagsDeferred.await(),
availableStates = EnumSet.of(
MangaState.ONGOING,
MangaState.FINISHED,
MangaState.PAUSED,
MangaState.ABANDONED,
),
availableContentRating = EnumSet.allOf(ContentRating::class.java),
availableDemographics = EnumSet.of(
Demographic.SHOUNEN,
Demographic.SHOUJO,
Demographic.SEINEN,
Demographic.JOSEI,
Demographic.NONE,
),
availableLocales = localesDeferred.await(),
)
}
private fun SearchableField.toParamName(): String = when (this) {
TITLE_NAME -> "title"
TAG -> "includedTags[]"
AUTHOR -> "authors[]"
STATE -> "status[]"
CONTENT_TYPE -> "contentType[]"
CONTENT_RATING -> "contentRating[]"
DEMOGRAPHIC -> "publicationDemographic[]"
ORIGINAL_LANGUAGE -> "originalLanguage[]"
LANGUAGE -> "availableTranslatedLanguage[]"
PUBLICATION_YEAR -> "year"
}
private fun Any?.toQueryParam(): String = when (this) {
is String -> urlEncoded()
is Locale -> if (language == "in") "id" else language
is MangaTag -> key
is MangaState -> when (this) {
MangaState.ONGOING -> "ongoing"
MangaState.FINISHED -> "completed"
MangaState.ABANDONED -> "cancelled"
MangaState.PAUSED -> "hiatus"
else -> ""
}
is ContentRating -> when (this) {
ContentRating.SAFE -> "safe"
// quick fix for double value
ContentRating.SUGGESTIVE -> "suggestive&contentRating[]=erotica"
ContentRating.ADULT -> "pornographic"
}
is Demographic -> when (this) { override val availableStates: Set<MangaState> =
Demographic.SHOUNEN -> "shounen" EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED, MangaState.ABANDONED)
Demographic.SHOUJO -> "shoujo"
Demographic.SEINEN -> "seinen"
Demographic.JOSEI -> "josei"
Demographic.NONE -> "none"
else -> ""
}
is SortOrder -> when (this) { override val isTagsExclusionSupported: Boolean = true
SortOrder.UPDATED -> "[latestUploadedChapter]=desc"
SortOrder.UPDATED_ASC -> "[latestUploadedChapter]=asc"
SortOrder.RATING -> "[rating]=desc"
SortOrder.RATING_ASC -> "[rating]=asc"
SortOrder.ALPHABETICAL -> "[title]=asc"
SortOrder.ALPHABETICAL_DESC -> "[title]=desc"
SortOrder.NEWEST -> "[year]=desc"
SortOrder.NEWEST_ASC -> "[year]=asc"
SortOrder.POPULARITY -> "[followedCount]=desc"
SortOrder.POPULARITY_ASC -> "[followedCount]=asc"
SortOrder.ADDED -> "[createdAt]=desc"
SortOrder.ADDED_ASC -> "[createdAt]=asc"
SortOrder.RELEVANCE -> "&order[relevance]=desc"
else -> "[latestUploadedChapter]=desc"
}
else -> this.toString().urlEncoded()
}
private fun StringBuilder.appendCriterion(field: SearchableField, value: Any?, paramName: String? = null) { override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
val param = paramName ?: field.toParamName() val domain = domain
if (param.isNotBlank()) {
append("&$param=")
append(value.toQueryParam())
}
}
override suspend fun getList(query: MangaSearchQuery): List<Manga> {
val url = buildString { val url = buildString {
append("https://api.$domain/manga?limit=$PAGE_SIZE&offset=${query.offset}") append("https://api.")
.append("&includes[]=cover_art&includes[]=author&includes[]=artist&includedTagsMode=AND&excludedTagsMode=OR") append(domain)
append("/manga?limit=")
var hasContentRating = false append(PAGE_SIZE)
append("&offset=")
append(offset)
append("&includes[]=cover_art&includes[]=author&includes[]=artist")
when (filter) {
is MangaListFilter.Search -> {
append("&title=")
append(filter.query)
}
query.criteria.forEach { criterion -> is MangaListFilter.Advanced -> {
when (criterion) { filter.tags.forEach {
is Include<*> -> { append("&includedTags[]=")
if (criterion.field == CONTENT_RATING) { append(it.key)
hasContentRating = true
}
criterion.values.forEach { appendCriterion(criterion.field, it) }
} }
is Exclude<*> -> { filter.tagsExclude.forEach {
criterion.values.forEach { appendCriterion(criterion.field, it, "excludedTags[]") } append("&excludedTags[]=")
append(it.key)
} }
is Match<*> -> { if (filter.contentRating.isNotEmpty()) {
appendCriterion(criterion.field, criterion.value) filter.contentRating.forEach {
when (it) {
ContentRating.SAFE -> append("&contentRating[]=safe")
ContentRating.SUGGESTIVE -> append("&contentRating[]=suggestive&contentRating[]=erotica")
ContentRating.ADULT -> append("&contentRating[]=pornographic")
}
}
} }
else -> { append("&order")
// Not supported append(
when (filter.sortOrder) {
SortOrder.UPDATED -> "[latestUploadedChapter]=desc"
SortOrder.RATING -> "[rating]=desc"
SortOrder.ALPHABETICAL -> "[title]=asc"
SortOrder.ALPHABETICAL_DESC -> "[title]=desc"
SortOrder.NEWEST -> "[createdAt]=desc"
SortOrder.POPULARITY -> "[followedCount]=desc"
},
)
filter.states.forEach {
append("&status[]=")
when (it) {
MangaState.ONGOING -> append("ongoing")
MangaState.FINISHED -> append("completed")
MangaState.ABANDONED -> append("cancelled")
MangaState.PAUSED -> append("hiatus")
else -> append("")
}
}
filter.locale?.let {
append("&availableTranslatedLanguage[]=")
append(it.language)
} }
} }
}
// If contentRating is not provided, add default values null -> {
if (!hasContentRating) { append("&order[latestUploadedChapter]=desc")
append("&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic") }
} }
append("&order")
append((query.order ?: defaultSortOrder).toQueryParam())
} }
val json = webClient.httpGet(url).parseJson().getJSONArray("data") val json = webClient.httpGet(url).parseJson().getJSONArray("data")
return json.mapJSON { jo -> jo.fetchManga(null) } 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 = when (attrs.getStringOrNull("contentRating")) {
"erotica", "pornographic" -> true
else -> false
},
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 (attrs.getStringOrNull("status")) {
"ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED
"hiatus" -> MangaState.PAUSED
"cancelled" -> MangaState.ABANDONED
else -> null
},
author = (relations["author"] ?: relations["artist"])
?.getJSONObject("attributes")
?.getStringOrNull("name"),
source = source,
)
}
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val domain = domain
val mangaId = manga.url.removePrefix("/") val mangaId = manga.url.removePrefix("/")
return getDetails(mangaId) val attrsDeferred = async {
}
override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? {
val regex = Regex("[0-9a-f\\-]{10,}", RegexOption.IGNORE_CASE)
val mangaId = link.pathSegments.find { regex.matches(it) } ?: return null
return getDetails(mangaId)
}
private suspend fun getDetails(mangaId: String): Manga = coroutineScope {
val jsonDeferred = async {
webClient.httpGet( webClient.httpGet(
"https://api.$domain/manga/${mangaId}?includes[]=artist&includes[]=author&includes[]=cover_art", "https://api.$domain/manga/${mangaId}?includes[]=artist&includes[]=author&includes[]=cover_art",
).parseJson().getJSONObject("data") ).parseJson().getJSONObject("data").getJSONObject("attributes")
} }
val feedDeferred = async { loadChapters(mangaId) } val feedDeferred = async { loadChapters(mangaId) }
jsonDeferred.await().fetchManga(mapChapters(feedDeferred.await())) val mangaAttrs = attrsDeferred.await()
val feed = feedDeferred.await()
manga.copy(
description = mangaAttrs.optJSONObject("description")?.selectByLocale()
?: manga.description,
chapters = mapChapters(feed),
)
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val json = webClient.httpGet( val domain = domain
"https://api.$domain/at-home/server/${chapter.url}?forcePort443=false", val chapterJson = webClient.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false")
).parseJson() .parseJson()
val chapterJson = json.getJSONObject("chapter") .getJSONObject("chapter")
val server = config[preferredServerKey] ?: SERVER_DATA val pages = chapterJson.getJSONArray("data")
val pages = chapterJson.getJSONArray( val prefix = "https://uploads.$domain/data/${chapterJson.getString("hash")}/"
if (server == SERVER_DATA_SAVER) "dataSaver" else "data",
)
val prefix = "${json.getString("baseUrl")}/$server/${chapterJson.getString("hash")}/"
return List(pages.length()) { i -> return List(pages.length()) { i ->
val url = prefix + pages.getString(i) val url = prefix + pages.getString(i)
MangaPage( MangaPage(
@ -299,7 +195,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : FlexibleMangaParser
} }
} }
private suspend fun fetchAvailableTags(): Set<MangaTag> { override suspend fun getAvailableTags(): Set<MangaTag> {
val tags = webClient.httpGet("https://api.${domain}/manga/tag").parseJson() val tags = webClient.httpGet("https://api.${domain}/manga/tag").parseJson()
.getJSONArray("data") .getJSONArray("data")
return tags.mapJSONToSet { jo -> return tags.mapJSONToSet { jo ->
@ -313,7 +209,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : FlexibleMangaParser
} }
} }
private suspend fun fetchAvailableLocales(): Set<Locale> { override suspend fun getAvailableLocales(): Set<Locale> {
val head = webClient.httpGet("https://$domain/").parseHtml().head() val head = webClient.httpGet("https://$domain/").parseHtml().head()
return head.getElementsByAttributeValue("property", "og:locale:alternate") return head.getElementsByAttributeValue("property", "og:locale:alternate")
.mapNotNullToSet { meta -> .mapNotNullToSet { meta ->
@ -322,64 +218,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : FlexibleMangaParser
} }
} }
private fun JSONObject.fetchManga(chapters: List<MangaChapter>?): Manga { private fun JSONObject.firstStringValue() = values().next() as String
val id = getString("id")
val attrs = getJSONObject("attributes")
val relations = getJSONArray("relationships").associateByKey("type")
val cover = relations["cover_art"]
?.firstOrNull()
?.getJSONObject("attributes")
?.getString("fileName")
?.let {
"https://uploads.$domain/covers/$id/$it"
}
val authors: Set<String> = (relations["author"] ?: relations["artist"])
?.mapNotNullToSet {
it.getJSONObject("attributes")?.getStringOrNull("name")
}.orEmpty()
return Manga(
id = generateUid(id),
title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
"Title should not be null"
},
altTitles = setOfNotNull(attrs.optJSONArray("altTitles")?.flatten()?.selectByLocale()), // TODO
url = id,
publicUrl = "https://$domain/title/$id",
rating = RATING_UNKNOWN,
contentRating = when (attrs.getStringOrNull("contentRating")) {
"pornographic" -> ContentRating.ADULT
"erotica", "suggestive" -> ContentRating.SUGGESTIVE
"safe" -> ContentRating.SAFE
else -> null
},
coverUrl = cover?.plus(".256.jpg"),
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 (attrs.getStringOrNull("status")) {
"ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED
"hiatus" -> MangaState.PAUSED
"cancelled" -> MangaState.ABANDONED
else -> null
},
authors = authors,
chapters = chapters,
source = source,
)
}
private fun JSONObject.firstStringValue() = entries<String>().first().value
private fun JSONObject.selectByLocale(): String? { private fun JSONObject.selectByLocale(): String? {
val preferredLocales = context.getPreferredLocales() val preferredLocales = context.getPreferredLocales()
@ -387,20 +226,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : FlexibleMangaParser
getStringOrNull(locale.language)?.let { return it } getStringOrNull(locale.language)?.let { return it }
getStringOrNull(locale.toLanguageTag())?.let { return it } getStringOrNull(locale.toLanguageTag())?.let { return it }
} }
return getStringOrNull(LOCALE_FALLBACK) ?: entries<String>().firstOrNull()?.value?.nullIfEmpty() return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
}
private fun JSONArray.flatten(): JSONObject {
val result = JSONObject()
repeat(length()) { i ->
val jo = optJSONObject(i)
if (jo != null) {
for (key in jo.keys()) {
result.put(key, jo.get(key))
}
}
}
return result
} }
private suspend fun loadChapters(mangaId: String): List<JSONObject> { private suspend fun loadChapters(mangaId: String): List<JSONObject> {
@ -446,7 +272,7 @@ internal class MangaDexParser(context: MangaLoaderContext) : FlexibleMangaParser
val json = webClient.httpGet(url).parseJson() val json = webClient.httpGet(url).parseJson()
if (json.getString("result") == "ok") { if (json.getString("result") == "ok") {
return Chapters( return Chapters(
data = json.optJSONArray("data")?.asTypedList<JSONObject>().orEmpty(), data = json.optJSONArray("data")?.toJSONList().orEmpty(),
total = json.getInt("total"), total = json.getInt("total"),
) )
} else { } else {
@ -464,52 +290,41 @@ internal class MangaDexParser(context: MangaLoaderContext) : FlexibleMangaParser
Locale.ROOT, Locale.ROOT,
) )
val chaptersBuilder = ChaptersListBuilder(list.size) val chaptersBuilder = ChaptersListBuilder(list.size)
val branchedChapters = HashMap<String?, HashMap<Pair<Int, Float>, MangaChapter>>() val branchedChapters = HashMap<String?, HashMap<Float, MangaChapter>>()
for (jo in list) { for (jo in list) {
val id = jo.getString("id") val id = jo.getString("id")
val attrs = jo.getJSONObject("attributes") val attrs = jo.getJSONObject("attributes")
if (!attrs.isNull("externalUrl")) { if (!attrs.isNull("externalUrl")) {
continue continue
} }
val number = attrs.getFloatOrDefault("chapter", 0f) val number = jo.getJSONObject("attributes").getFloatOrDefault("chapter", 0f)
val volume = attrs.getIntOrDefault("volume", 0)
val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) } val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) }
val lc = locale?.getDisplayName(locale)?.toTitleCase(locale) val lc = locale?.getDisplayName(locale)?.toTitleCase(locale)
val relations = jo.getJSONArray("relationships").associateByKey("type") val relations = jo.getJSONArray("relationships").associateByKey("type")
val team = val team = relations["scanlation_group"]?.getJSONObject("attributes")?.getStringOrNull("name")
relations["scanlation_group"]?.firstOrNull()?.optJSONObject("attributes")?.getStringOrNull("name") ?.takeUnless { it.isBlank() }
val branch = (list.indices).firstNotNullOf { i -> val branch = (list.indices).firstNotNullOf { i ->
val b = if (i == 0) lc else "$lc ($i)" val b = if (i == 0) lc else "$lc ($i)"
if (branchedChapters[b]?.get(volume to number) == null) b else null if (branchedChapters[b]?.get(number) == null) b else null
} }
val chapter = MangaChapter( val chapter = MangaChapter(
id = generateUid(id), id = generateUid(id),
title = attrs.getStringOrNull("title"), name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
number = number, ?: "Chapter #${number.toString().removeSuffix(".0")}",
volume = volume, number = if (number <= 0f) (branchedChapters[branch]?.size?.plus(1) ?: 0) else number.toInt(),
url = id, url = id,
scanlator = team, scanlator = team,
uploadDate = dateFormat.parseSafe(attrs.getString("publishAt")), uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
branch = branch, branch = branch,
source = source, source = source,
) )
if (chaptersBuilder.add(chapter)) { if (chaptersBuilder.add(chapter)) {
branchedChapters.getOrPut(branch, ::HashMap)[volume to number] = chapter branchedChapters.getOrPut(branch, ::HashMap)[number] = chapter
} }
} }
return chaptersBuilder.toList() return chaptersBuilder.toList()
} }
private fun JSONArray.associateByKey(key: String): Map<String, List<JSONObject>> {
val destination = LinkedHashMap<String, MutableList<JSONObject>>(length())
repeat(length()) { i ->
val item = getJSONObject(i)
val keyValue = item.getString(key)
destination.computeIfAbsent(keyValue) { mutableListOf() }.add(item)
}
return destination
}
private class Chapters( private class Chapters(
val data: List<JSONObject>, val data: List<JSONObject>,
val total: Int, val total: Int,

@ -1,493 +0,0 @@
package org.koitharu.kotatsu.parsers.site.all
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.bitmap.Rect
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.min
private const val PIECE_SIZE = 200
private const val MIN_SPLIT_COUNT = 5
internal abstract class MangaFireParser(
context: MangaLoaderContext,
source: MangaParserSource,
private val siteLang: String,
) : PagedMangaParser(context, source, 30), Interceptor, MangaParserAuthProvider {
override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("mangafire.to")
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.POPULARITY,
SortOrder.RATING,
SortOrder.NEWEST,
SortOrder.ALPHABETICAL,
SortOrder.RELEVANCE,
)
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val authUrl: String
get() = "https://${domain}"
override suspend fun isAuthorized(): Boolean {
return context.cookieJar.getCookies(domain).any {
it.value.contains("user")
}
}
override suspend fun getUsername(): String {
val body = webClient.httpGet("https://${domain}/user/profile").parseHtml().body()
return body.selectFirst("form.ajax input[name*=username]")?.attr("value")
?: body.parseFailed("Cannot find username")
}
private val tags = suspendLazy(soft = true) {
webClient.httpGet("https://$domain/filter").parseHtml()
.select(".genres > li").map {
MangaTag(
title = it.selectFirstOrThrow("label").ownText().toTitleCase(sourceLocale),
key = it.selectFirstOrThrow("input").attr("value"),
source = source,
)
}.associateBy { it.title }
}
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities(
isMultipleTagsSupported = true,
isTagsExclusionSupported = true,
isSearchSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = tags.get().values.toSet(),
availableStates = EnumSet.of(
MangaState.ONGOING,
MangaState.FINISHED,
MangaState.ABANDONED,
MangaState.PAUSED,
MangaState.UPCOMING,
),
)
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val url = "https://$domain/filter".toHttpUrl().newBuilder().apply {
addQueryParameter("page", page.toString())
addQueryParameter("language[]", siteLang)
when {
!filter.query.isNullOrEmpty() -> {
val encodedQuery = filter.query.splitByWhitespace().joinToString(separator = "+") { part ->
part.urlEncoded()
}
addEncodedQueryParameter("keyword", encodedQuery)
addQueryParameter(
name = "sort",
value = when (order) {
SortOrder.UPDATED -> "recently_updated"
SortOrder.POPULARITY -> "most_viewed"
SortOrder.RATING -> "scores"
SortOrder.NEWEST -> "release_date"
SortOrder.ALPHABETICAL -> "title_az"
SortOrder.RELEVANCE -> "most_relevance"
else -> ""
},
)
}
else -> {
filter.tagsExclude.forEach { tag ->
addQueryParameter("genre[]", "-${tag.key}")
}
filter.tags.forEach { tag ->
addQueryParameter("genre[]", tag.key)
}
filter.locale?.let {
addQueryParameter("language[]", it.language)
}
filter.states.forEach { state ->
addQueryParameter(
name = "status[]",
value = when (state) {
MangaState.ONGOING -> "releasing"
MangaState.FINISHED -> "completed"
MangaState.ABANDONED -> "discontinued"
MangaState.PAUSED -> "on_hiatus"
MangaState.UPCOMING -> "info"
else -> throw IllegalArgumentException("$state not supported")
},
)
}
addQueryParameter(
name = "sort",
value = when (order) {
SortOrder.UPDATED -> "recently_updated"
SortOrder.POPULARITY -> "most_viewed"
SortOrder.RATING -> "scores"
SortOrder.NEWEST -> "release_date"
SortOrder.ALPHABETICAL -> "title_az"
SortOrder.RELEVANCE -> "most_relevance"
else -> ""
},
)
}
}
}.build()
return webClient.httpGet(url)
.parseHtml().parseMangaList()
}
private fun Document.parseMangaList(): List<Manga> {
return select(".original.card-lg .unit .inner").map {
val a = it.selectFirstOrThrow(".info > a")
val mangaUrl = a.attrAsRelativeUrl("href")
Manga(
id = generateUid(mangaUrl),
url = mangaUrl,
publicUrl = mangaUrl.toAbsoluteUrl(domain),
title = a.ownText(),
coverUrl = it.selectFirstOrThrow("img").attrAsAbsoluteUrl("src"),
source = source,
altTitles = emptySet(),
largeCoverUrl = null,
authors = emptySet(),
contentRating = null,
rating = RATING_UNKNOWN,
state = null,
tags = emptySet(),
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val document = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val availableTags = tags.get()
var isAdult = false
var isSuggestive = false
val author = document.select("div.meta a[href*=/author/]")
.joinToString { it.ownText() }.nullIfEmpty()
return manga.copy(
title = document.selectFirstOrThrow(".info > h1").ownText(),
altTitles = setOfNotNull(document.selectFirst(".info > h6")?.ownTextOrNull()),
rating = document.selectFirst("div.rating-box")?.attr("data-score")
?.toFloatOrNull()?.div(10) ?: RATING_UNKNOWN,
coverUrl = document.selectFirstOrThrow("div.manga-detail div.poster img")
.attrAsAbsoluteUrl("src"),
tags = document.select("div.meta a[href*=/genre/]").mapNotNullToSet {
val tag = it.ownText()
if (tag == "Hentai") {
isAdult = true
} else if (tag == "Ecchi") {
isSuggestive = true
}
availableTags[tag.toTitleCase(sourceLocale)]
},
contentRating = when {
isAdult -> ContentRating.ADULT
isSuggestive -> ContentRating.SUGGESTIVE
else -> ContentRating.SAFE
},
state = document.selectFirst(".info > p")?.ownText()?.let {
when (it.lowercase()) {
"releasing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED
"discontinued" -> MangaState.ABANDONED
"on_hiatus" -> MangaState.PAUSED
"info" -> MangaState.UPCOMING
else -> null
}
},
authors = setOfNotNull(author),
description = document.selectFirstOrThrow("#synopsis div.modal-content").html(),
chapters = getChapters(manga.url, document),
)
}
private data class ChapterBranch(
val type: String,
val langCode: String,
val langTitle: String,
)
private suspend fun getChapters(mangaUrl: String, document: Document): List<MangaChapter> {
val availableTypes = document.select(".chapvol-tab > a").map {
it.attr("data-name")
}
val langTypePairs = document.select(".m-list div.tab-content").flatMap {
val type = it.attr("data-name")
it.select(".list-menu .dropdown-item").map { item ->
ChapterBranch(
type = type,
langCode = item.attr("data-code").lowercase(),
langTitle = item.attr("data-title"),
)
}
}.filter {
it.langCode == siteLang && availableTypes.contains(it.type)
}
val id = mangaUrl.substringAfterLast('.')
return coroutineScope {
langTypePairs.map {
async {
getChaptersBranch(id, it)
}
}.awaitAll().flatten()
}
}
private suspend fun getChaptersBranch(mangaId: String, branch: ChapterBranch): List<MangaChapter> {
val chapterElements = webClient
.httpGet("https://$domain/ajax/read/$mangaId/${branch.type}/${branch.langCode}")
.parseJson()
.getJSONObject("result")
.getString("html")
.let(Jsoup::parseBodyFragment)
.select("ul li a")
if (branch.type == "chapter") {
val doc = webClient
.httpGet("https://$domain/ajax/manga/$mangaId/${branch.type}/${branch.langCode}")
.parseJson()
.getString("result")
.let(Jsoup::parseBodyFragment)
doc.select("ul li a").withIndex().forEach { (i, it) ->
val date = it.select("span")[1].ownText()
chapterElements[i].attr("upload-date", date)
chapterElements[i].attr("other-title", it.attr("title"))
}
}
return chapterElements.mapChapters(reversed = true) { _, it ->
MangaChapter(
id = generateUid(it.attr("href")),
title = it.attr("title").ifBlank {
"${branch.type.toTitleCase()} ${it.attr("data-number")}"
},
number = it.attr("data-number").toFloat(),
volume = it.attr("other-title").let {
volumeNumRegex.find(it)?.groupValues?.getOrNull(2)?.toInt() ?: 0
},
url = "${branch.type}/${it.attr("data-id")}",
scanlator = null,
uploadDate = dateFormat.parseSafe(it.attr("upload-date")),
branch = "${branch.langTitle} ${branch.type.toTitleCase()}",
source = source,
)
}
}
private val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.ENGLISH)
private val volumeNumRegex = Regex("""vol(ume)?\s*(\d+)""", RegexOption.IGNORE_CASE)
override suspend fun getRelatedManga(seed: Manga): List<Manga> = coroutineScope {
val document = webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml()
val total = document.select(
"section.m-related a[href*=/manga/], .side-manga:not(:has(.head:contains(trending))) .unit",
).size
val mangas = ArrayList<Manga>(total)
// "Related Manga"
document.select("section.m-related a[href*=/manga/]").map {
async {
val url = it.attrAsRelativeUrl("href")
val mangaDocument = webClient
.httpGet(url.toAbsoluteUrl(domain))
.parseHtml()
val chaptersInManga = mangaDocument.select(".m-list div.tab-content .list-menu .dropdown-item")
.map { it.attr("data-code").lowercase() }
if (!chaptersInManga.contains(siteLang)) {
return@async null
}
Manga(
id = generateUid(url),
url = url,
publicUrl = url.toAbsoluteUrl(domain),
title = it.ownText(),
coverUrl = mangaDocument.selectFirstOrThrow("div.manga-detail div.poster img")
.attrAsAbsoluteUrl("src"),
source = source,
altTitles = emptySet(),
largeCoverUrl = null,
authors = emptySet(),
contentRating = null,
rating = RATING_UNKNOWN,
state = null,
tags = emptySet(),
)
}
}.awaitAll()
.filterNotNullTo(mangas)
// "You may also like"
document.select(".side-manga:not(:has(.head:contains(trending))) .unit").forEach {
val url = it.attrAsRelativeUrl("href")
mangas.add(
Manga(
id = generateUid(url),
url = url,
publicUrl = url.toAbsoluteUrl(domain),
title = it.selectFirstOrThrow(".info h6").ownText(),
coverUrl = it.selectFirstOrThrow(".poster img").attrAsAbsoluteUrl("src"),
source = source,
altTitles = emptySet(),
largeCoverUrl = null,
authors = emptySet(),
contentRating = null,
rating = RATING_UNKNOWN,
state = null,
tags = emptySet(),
),
)
}
mangas.ifEmpty {
// fallback: author's other works
document.select("div.meta a[href*=/author/]").map {
async {
val url = it.attrAsAbsoluteUrl("href").toHttpUrl()
.newBuilder()
.addQueryParameter("language[]", siteLang)
.build()
webClient.httpGet(url)
.parseHtml().parseMangaList()
}
}.awaitAll().flatten()
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val images = webClient
.httpGet("https://$domain/ajax/read/${chapter.url}")
.parseJson()
.getJSONObject("result")
.getJSONArray("images")
val pages = ArrayList<MangaPage>(images.length())
for (i in 0 until images.length()) {
val img = images.getJSONArray(i)
val url = img.getString(0)
val offset = img.getInt(2)
pages.add(
MangaPage(
id = generateUid(url),
url = if (offset < 1) {
url
} else {
"$url#scrambled_$offset"
},
preview = null,
source = source,
),
)
}
return pages
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
if (request.url.fragment?.startsWith("scrambled") != true) {
return response
}
return context.redrawImageResponse(response) { bitmap ->
val offset = request.url.fragment!!.substringAfter("_").toInt()
val width = bitmap.width
val height = bitmap.height
val result = context.createBitmap(width, height)
val pieceWidth = min(PIECE_SIZE, width.ceilDiv(MIN_SPLIT_COUNT))
val pieceHeight = min(PIECE_SIZE, height.ceilDiv(MIN_SPLIT_COUNT))
val xMax = width.ceilDiv(pieceWidth) - 1
val yMax = height.ceilDiv(pieceHeight) - 1
for (y in 0..yMax) {
for (x in 0..xMax) {
val xDst = pieceWidth * x
val yDst = pieceHeight * y
val w = min(pieceWidth, width - xDst)
val h = min(pieceHeight, height - yDst)
val xSrc = pieceWidth * when (x) {
xMax -> x // margin
else -> (xMax - x + offset) % xMax
}
val ySrc = pieceHeight * when (y) {
yMax -> y // margin
else -> (yMax - y + offset) % yMax
}
val srcRect = Rect(xSrc, ySrc, xSrc + w, ySrc + h)
val dstRect = Rect(xDst, yDst, xDst + w, yDst + h)
result.drawBitmap(bitmap, srcRect, dstRect)
}
}
result
}
}
private fun Int.ceilDiv(other: Int) = (this + (other - 1)) / other
@MangaSourceParser("MANGAFIRE_EN", "MangaFire English", "en")
class English(context: MangaLoaderContext) : MangaFireParser(context, MangaParserSource.MANGAFIRE_EN, "en")
@MangaSourceParser("MANGAFIRE_ES", "MangaFire Spanish", "es")
class Spanish(context: MangaLoaderContext) : MangaFireParser(context, MangaParserSource.MANGAFIRE_ES, "es")
@MangaSourceParser("MANGAFIRE_ESLA", "MangaFire Spanish (Latim)", "es")
class SpanishLatim(context: MangaLoaderContext) :
MangaFireParser(context, MangaParserSource.MANGAFIRE_ESLA, "es-la")
@MangaSourceParser("MANGAFIRE_FR", "MangaFire French", "fr")
class French(context: MangaLoaderContext) : MangaFireParser(context, MangaParserSource.MANGAFIRE_FR, "fr")
@MangaSourceParser("MANGAFIRE_JA", "MangaFire Japanese", "ja")
class Japanese(context: MangaLoaderContext) : MangaFireParser(context, MangaParserSource.MANGAFIRE_JA, "ja")
@MangaSourceParser("MANGAFIRE_PT", "MangaFire Portuguese", "pt")
class Portuguese(context: MangaLoaderContext) : MangaFireParser(context, MangaParserSource.MANGAFIRE_PT, "pt")
@MangaSourceParser("MANGAFIRE_PTBR", "MangaFire Portuguese (Brazil)", "pt")
class PortugueseBR(context: MangaLoaderContext) :
MangaFireParser(context, MangaParserSource.MANGAFIRE_PTBR, "pt-br")
}

@ -4,154 +4,102 @@ import androidx.collection.ArrayMap
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@MangaSourceParser("MANGAPARK", "MangaPark") @MangaSourceParser("MANGAPARK", "MangaPark")
internal class MangaPark(context: MangaLoaderContext) : internal class MangaPark(context: MangaLoaderContext) :
PagedMangaParser(context, MangaParserSource.MANGAPARK, pageSize = 36) { PagedMangaParser(context, MangaSource.MANGAPARK, pageSize = 36) {
override val configKeyDomain = ConfigKey.Domain( override val availableSortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java)
"mangapark.net",
"mangapark.com",
"mangapark.org",
"mangapark.me",
"mangapark.io",
"mangapark.to",
"comicpark.org",
"comicpark.to",
"readpark.org",
"readpark.net",
"parkmanga.com",
"parkmanga.net",
"parkmanga.org",
"mpark.to",
)
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) { override val availableStates: Set<MangaState> = EnumSet.allOf(MangaState::class.java)
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val availableSortOrders: Set<SortOrder> = override val availableContentRating: Set<ContentRating> = EnumSet.of(ContentRating.SAFE)
EnumSet.of(SortOrder.POPULARITY, SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.ALPHABETICAL, SortOrder.RATING)
override val filterCapabilities: MangaListFilterCapabilities override val isTagsExclusionSupported: Boolean = true
get() = MangaListFilterCapabilities(
isMultipleTagsSupported = true,
isTagsExclusionSupported = true,
isSearchSupported = true,
isSearchWithFiltersSupported = true,
isOriginalLocaleSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions( override val configKeyDomain = ConfigKey.Domain("mangapark.net")
availableTags = tagsMap.get().values.toSet(),
availableStates = EnumSet.of( private val tagsMap = SuspendLazy(::parseTags)
MangaState.ONGOING,
MangaState.FINISHED,
MangaState.ABANDONED,
MangaState.PAUSED,
MangaState.UPCOMING,
),
availableContentRating = EnumSet.of(ContentRating.SAFE),
availableLocales = setOf(
Locale("af"), Locale("sq"), Locale("am"), Locale("ar"), Locale("hy"),
Locale("az"), Locale("be"), Locale("bn"), Locale("zh_hk"), Locale("zh_tw"),
Locale.CHINESE, Locale("ceb"), Locale("ca"), Locale("km"), Locale("my"),
Locale("bg"), Locale("bs"), Locale("hr"), Locale("cs"), Locale("da"),
Locale("nl"), Locale.ENGLISH, Locale("et"), Locale("fo"), Locale("fil"),
Locale("fi"), Locale("he"), Locale("ha"), Locale("jv"), Locale("lb"),
Locale("mn"), Locale("ro"), Locale("si"), Locale("ta"), Locale("uz"),
Locale("ur"), Locale("tg"), Locale("sd"), Locale("pt_br"), Locale("mo"),
Locale("lt"), Locale.JAPANESE, Locale.ITALIAN, Locale("ht"), Locale("lv"),
Locale("mr"), Locale("pt"), Locale("sn"), Locale("sv"), Locale("uk"),
Locale("tk"), Locale("sw"), Locale("st"), Locale("pl"), Locale("mi"),
Locale("lo"), Locale("ga"), Locale("gu"), Locale("gn"), Locale("id"),
Locale("ky"), Locale("mt"), Locale("fa"), Locale("sh"), Locale("es_419"),
Locale("tr"), Locale("to"), Locale("vi"), Locale("es"), Locale("sr"),
Locale("ps"), Locale("ml"), Locale("ku"), Locale("ig"), Locale("el"),
Locale.GERMAN, Locale("is"), Locale.KOREAN, Locale("ms"), Locale("ny"), Locale("sm"),
Locale("so"), Locale("ti"), Locale("zu"), Locale("yo"), Locale("th"),
Locale("sl"), Locale("ru"), Locale("no"), Locale("mg"), Locale("kk"),
Locale("hu"), Locale("ka"), Locale.FRENCH, Locale("hi"), Locale("kn"),
Locale("mk"), Locale("ne"), Locale("rm"), Locale("sk"), Locale("te"),
),
)
init { init {
context.cookieJar.insertCookies(domain, "nsfw", "2") context.cookieJar.insertCookies(domain, "nsfw", "2")
} }
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> { override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/search?page=") append("/search?page=")
append(page.toString()) append(page.toString())
filter.query?.let { when (filter) {
append("&word=") is MangaListFilter.Search -> {
append(filter.query.urlEncoded()) append("&word=")
} append(filter.query.urlEncoded())
}
append("&genres=")
filter.tags.joinTo(this, ",") { it.key }
append("|")
filter.tagsExclude.joinTo(this, ",") { it.key }
if (filter.contentRating.isNotEmpty()) { is MangaListFilter.Advanced -> {
filter.contentRating.oneOrThrowIfMany()?.let {
append("&genres=")
if (filter.tags.isNotEmpty()) {
appendAll(filter.tags, ",") { it.key }
}
append("|")
if (filter.tagsExclude.isNotEmpty()) {
appendAll(filter.tagsExclude, ",") { 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("")
},
)
}
}
filter.states.oneOrThrowIfMany()?.let {
append("&status=")
append(
when (it) {
MangaState.ONGOING -> "ongoing"
MangaState.FINISHED -> "completed"
MangaState.PAUSED -> "hiatus"
MangaState.ABANDONED -> "cancelled"
MangaState.UPCOMING -> "pending"
},
)
}
append("&sortby=")
append( append(
when (it) { when (filter.sortOrder) {
ContentRating.SAFE -> append(",gore,bloody,violence,ecchi,adult,mature,smut,hentai") SortOrder.POPULARITY -> "views_d000"
else -> append("") SortOrder.UPDATED -> "field_update"
SortOrder.NEWEST -> "field_create"
SortOrder.ALPHABETICAL -> "field_name"
SortOrder.RATING -> "field_score"
else -> ""
}, },
) )
}
}
filter.states.oneOrThrowIfMany()?.let {
append("&status=")
append(
when (it) {
MangaState.ONGOING -> "ongoing"
MangaState.FINISHED -> "completed"
MangaState.PAUSED -> "hiatus"
MangaState.ABANDONED -> "cancelled"
MangaState.UPCOMING -> "pending"
else -> throw IllegalArgumentException("$it not supported")
},
)
}
append("&sortby=") filter.locale?.let {
append( append("&lang=")
when (order) { append(it.language)
SortOrder.POPULARITY -> "views_d000" }
SortOrder.UPDATED -> "field_update" }
SortOrder.NEWEST -> "field_create"
SortOrder.ALPHABETICAL -> "field_name"
SortOrder.RATING -> "field_score"
else -> ""
},
)
filter.locale?.let {
append("&lang=")
append(it.language)
}
filter.originalLocale?.let { null -> append("&sortby=field_update")
append("&orig=")
append(it.language)
} }
} }
@ -162,20 +110,22 @@ internal class MangaPark(context: MangaLoaderContext) :
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.toAbsoluteUrl(div.host ?: domain), publicUrl = href.toAbsoluteUrl(div.host ?: domain),
coverUrl = div.selectFirst("img")?.src(), coverUrl = div.selectFirst("img")?.src().orEmpty(),
title = div.selectFirst("h3")?.text().orEmpty(), title = div.selectFirst("h3")?.text().orEmpty(),
altTitles = emptySet(), altTitle = null,
rating = div.selectFirst("span.text-yellow-500")?.text()?.toFloatOrNull()?.div(10F) ?: RATING_UNKNOWN, rating = div.selectFirst("span.text-yellow-500")?.text()?.toFloatOrNull()?.div(10F) ?: RATING_UNKNOWN,
tags = emptySet(), tags = emptySet(),
authors = emptySet(), author = null,
state = null, state = null,
source = source, source = source,
contentRating = if (isNsfwSource) ContentRating.ADULT else null, isNsfw = isNsfwSource,
) )
} }
} }
private val tagsMap = suspendLazy(initializer = ::parseTags) override suspend fun getAvailableTags(): Set<MangaTag> {
return tagsMap.get().values.toSet()
}
private suspend fun parseTags(): Map<String, MangaTag> { private suspend fun parseTags(): Map<String, MangaTag> {
val tagElements = webClient.httpGet("https://$domain/search").parseHtml() val tagElements = webClient.httpGet("https://$domain/search").parseHtml()
@ -193,6 +143,29 @@ internal class MangaPark(context: MangaLoaderContext) :
return tagMap return tagMap
} }
override suspend fun getAvailableLocales(): Set<Locale> = setOf(
Locale("af"), Locale("sq"), Locale("am"), Locale("ar"), Locale("hy"),
Locale("az"), Locale("be"), Locale("bn"), Locale("zh_hk"), Locale("zh_tw"),
Locale.CHINESE, Locale("ceb"), Locale("ca"), Locale("km"), Locale("my"),
Locale("bg"), Locale("bs"), Locale("hr"), Locale("cs"), Locale("da"),
Locale("nl"), Locale.ENGLISH, Locale("et"), Locale("fo"), Locale("fil"),
Locale("fi"), Locale("he"), Locale("ha"), Locale("jv"), Locale("lb"),
Locale("mn"), Locale("ro"), Locale("si"), Locale("ta"), Locale("uz"),
Locale("ur"), Locale("tg"), Locale("sd"), Locale("pt_br"), Locale("mo"),
Locale("lt"), Locale.JAPANESE, Locale.ITALIAN, Locale("ht"), Locale("lv"),
Locale("mr"), Locale("pt"), Locale("sn"), Locale("sv"), Locale("uk"),
Locale("tk"), Locale("sw"), Locale("st"), Locale("pl"), Locale("mi"),
Locale("lo"), Locale("ga"), Locale("gu"), Locale("gn"), Locale("id"),
Locale("ky"), Locale("mt"), Locale("fa"), Locale("sh"), Locale("es_419"),
Locale("tr"), Locale("to"), Locale("vi"), Locale("es"), Locale("sr"),
Locale("ps"), Locale("ml"), Locale("ku"), Locale("ig"), Locale("el"),
Locale.GERMAN, Locale("is"), Locale.KOREAN, Locale("ms"), Locale("ny"), Locale("sm"),
Locale("so"), Locale("ti"), Locale("zu"), Locale("yo"), Locale("th"),
Locale("sl"), Locale("ru"), Locale("no"), Locale("mg"), Locale("kk"),
Locale("hu"), Locale("ka"), Locale.FRENCH, Locale("hi"), Locale("kn"),
Locale("mk"), Locale("ne"), Locale("rm"), Locale("sk"), Locale("te"),
)
override suspend fun getDetails(manga: Manga): Manga = coroutineScope { override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val tagMap = tagsMap.get() val tagMap = tagsMap.get()
@ -200,11 +173,10 @@ internal class MangaPark(context: MangaLoaderContext) :
val tags = selectTag.mapNotNullToSet { tagMap[it.text()] } val tags = selectTag.mapNotNullToSet { tagMap[it.text()] }
val nsfw = tags.any { t -> t.key == "hentai" || t.key == "adult" } val nsfw = tags.any { t -> t.key == "hentai" || t.key == "adult" }
val dateFormat = SimpleDateFormat("dd/MM/yyyy", sourceLocale) val dateFormat = SimpleDateFormat("dd/MM/yyyy", sourceLocale)
val author = doc.selectFirst("div[q:key=tz_4]")?.textOrNull()
manga.copy( manga.copy(
altTitles = setOfNotNull(doc.selectFirst("div[q:key=tz_2]")?.textOrNull()), altTitle = doc.selectFirst("div[q:key=tz_2]")?.text().orEmpty(),
authors = setOfNotNull(author), author = doc.selectFirst("div[q:key=tz_4]")?.text().orEmpty(),
description = doc.selectFirst("react-island[q:key=0a_9]")?.html(), description = doc.selectFirst("react-island[q:key=0a_9]")?.html().orEmpty(),
state = when (doc.selectFirst("span[q:key=Yn_5]")?.text()?.lowercase()) { state = when (doc.selectFirst("span[q:key=Yn_5]")?.text()?.lowercase()) {
"ongoing" -> MangaState.ONGOING "ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED "completed" -> MangaState.FINISHED
@ -213,16 +185,15 @@ internal class MangaPark(context: MangaLoaderContext) :
else -> null else -> null
}, },
tags = tags, tags = tags,
contentRating = if (nsfw) ContentRating.ADULT else ContentRating.SAFE, isNsfw = nsfw,
chapters = doc.body().select("div.group.flex div.px-2").mapChapters(reversed = true) { i, div -> chapters = doc.body().select("div.group.flex div.px-2").mapChapters { i, div ->
val a = div.selectFirstOrThrow("a") val a = div.selectFirstOrThrow("a")
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
val dateText = div.selectFirst("span[q:key=Ee_0]")?.text() val dateText = div.selectFirst("span[q:key=Ee_0]")?.text()
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
title = a.textOrNull(), name = a.text(),
number = i + 1f, number = i + 1,
volume = 0,
url = href, url = href,
uploadDate = parseChapterDate( uploadDate = parseChapterDate(
dateFormat, dateFormat,
@ -239,20 +210,15 @@ internal class MangaPark(context: MangaLoaderContext) :
private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long { private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
val d = date?.lowercase() ?: return 0 val d = date?.lowercase() ?: return 0
return when { return when {
WordSet(" ago").endsWith(d) -> { d.endsWith(" ago") -> parseRelativeDate(date)
parseRelativeDate(d) d.startsWith("just now") -> Calendar.getInstance().apply {
} set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
WordSet("just now").startsWith(d) -> { set(Calendar.SECOND, 0)
Calendar.getInstance().apply { set(Calendar.MILLISECOND, 0)
set(Calendar.HOUR_OF_DAY, 0) }.timeInMillis
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0) else -> dateFormat.tryParse(date)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
else -> dateFormat.parseSafe(date)
} }
} }
@ -260,24 +226,18 @@ internal class MangaPark(context: MangaLoaderContext) :
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0 val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
return when { return when {
WordSet("second") WordSet("second").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
.anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis WordSet("minute", "minutes", "mins", "min").anyWordIn(date) -> cal.apply {
add(
WordSet("minute", "minutes", "mins", "min") Calendar.MINUTE,
.anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis -number,
)
WordSet("hour", "hours") }.timeInMillis
.anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
WordSet("day", "days")
.anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
WordSet("month", "months")
.anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
WordSet("year")
.anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
WordSet("hour", "hours").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
WordSet("day", "days").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
WordSet("month", "months").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
WordSet("year").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
else -> 0 else -> 0
} }
} }
@ -285,32 +245,24 @@ internal class MangaPark(context: MangaLoaderContext) :
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
val id = chapter.url.removeSuffix('/').substringAfterLast('/').substringBefore('-') val script = if (doc.selectFirst("script:containsData(comic-)") != null) {
val s = doc.selectFirstOrThrow("script:containsData($id)").data() doc.selectFirstOrThrow("script:containsData(comic-)").data()
.substringAfterLast("\"comic-").split("\",\"")
val script = if (s.contains("\"comic-")) {
s.substringAfterLast("\"comic-")
} else { } else {
s.substringAfterLast("\"manga-") doc.selectFirstOrThrow("script:containsData(manga-)").data()
.substringAfterLast("\"manga-").split("\",\"")
} }
return script.mapNotNull { url ->
return Regex("\"(https?:.+?)\"") if (!url.startsWith("https://")) {
.findAll(script) return@mapNotNull null
.mapIndexedNotNullTo(ArrayList()) { i, it -> } else {
val url = it.groupValues.getOrNull(1) ?: return@mapIndexedNotNullTo null MangaPage(
if (url.contains(".jpg") || url.contains(".jpeg") || url.contains(".jfif") || url.contains(".pjpeg") || id = generateUid(url),
url.contains(".pjp") || url.contains(".png") || url.contains(".webp") || url.contains(".avif") || url = url,
url.contains(".gif") preview = null,
) { source = source,
MangaPage( )
id = generateUid(url),
url = url,
preview = null,
source = source,
)
} else {
return@mapIndexedNotNullTo null
}
} }
}
} }
} }

@ -9,58 +9,53 @@ import okhttp3.ResponseBody.Companion.toResponseBody
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey 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.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.asTypedList
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import org.koitharu.kotatsu.parsers.util.json.toJSONList
import java.util.* import java.util.*
internal abstract class MangaPlusParser( internal abstract class MangaPlusParser(
context: MangaLoaderContext, context: MangaLoaderContext,
source: MangaParserSource, source: MangaSource,
private val sourceLang: String, private val sourceLang: String,
) : SinglePageMangaParser(context, source), Interceptor { ) : MangaParser(context, source), Interceptor {
private val apiUrl = "https://jumpg-webapi.tokyo-cdn.com/api" private val apiUrl = "https://jumpg-webapi.tokyo-cdn.com/api"
override val configKeyDomain = ConfigKey.Domain("mangaplus.shueisha.co.jp") override val configKeyDomain = ConfigKey.Domain("mangaplus.shueisha.co.jp")
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val availableSortOrders: Set<SortOrder> = EnumSet.of( override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.UPDATED, SortOrder.UPDATED,
SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL,
) )
override val filterCapabilities: MangaListFilterCapabilities private val extraHeaders = Headers.headersOf("Session-Token", UUID.randomUUID().toString())
get() = MangaListFilterCapabilities(
isSearchSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions() // no tags or tag search available
override suspend fun getAvailableTags(): Set<MangaTag> = emptySet()
private val extraHeaders = Headers.headersOf("Session-Token", UUID.randomUUID().toString()) override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
if (offset > 0) {
return emptyList()
}
override suspend fun getList(order: SortOrder, filter: MangaListFilter): List<Manga> { return when (filter) {
return when { is MangaListFilter.Advanced -> {
filter.query.isNullOrEmpty() -> { when (filter.sortOrder) {
when (order) {
SortOrder.POPULARITY -> getPopularList() SortOrder.POPULARITY -> getPopularList()
SortOrder.UPDATED -> getLatestList() SortOrder.UPDATED -> getLatestList()
else -> getAllTitleList() else -> getAllTitleList()
} }
} }
else -> getAllTitleList(filter.query) is MangaListFilter.Search -> getAllTitleList(filter.query)
else -> getAllTitleList()
} }
} }
@ -69,7 +64,7 @@ internal abstract class MangaPlusParser(
return json.getJSONObject("titleRankingView") return json.getJSONObject("titleRankingView")
.getJSONArray("titles") .getJSONArray("titles")
.asTypedList<JSONObject>() .toJSONList()
.toMangaList() .toMangaList()
} }
@ -83,11 +78,11 @@ internal abstract class MangaPlusParser(
} }
// since search is local, save network calls on related manga call // since search is local, save network calls on related manga call
private val allTitleCache = suspendLazy { private val allTitleCache = SuspendLazy {
apiCall("/title_list/allV2") apiCall("/title_list/allV2")
.getJSONObject("allTitlesViewV2") .getJSONObject("allTitlesViewV2")
.getJSONArray("AllTitlesGroup") .getJSONArray("AllTitlesGroup")
.mapJSON { it.getJSONArray("titles").asTypedList<JSONObject>() } .mapJSON { it.getJSONArray("titles").toJSONList() }
.flatten() .flatten()
} }
@ -121,9 +116,9 @@ internal abstract class MangaPlusParser(
publicUrl = "/titles/$titleId".toAbsoluteUrl(domain), publicUrl = "/titles/$titleId".toAbsoluteUrl(domain),
title = name, title = name,
coverUrl = it.getString("portraitImageUrl"), coverUrl = it.getString("portraitImageUrl"),
altTitles = emptySet(), altTitle = null,
authors = setOf(author), author = author,
contentRating = null, isNsfw = false,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
state = null, state = null,
source = source, source = source,
@ -143,14 +138,13 @@ internal abstract class MangaPlusParser(
} }
val hiatus = json.getStringOrNull("nonAppearanceInfo")?.contains("on a hiatus") == true val hiatus = json.getStringOrNull("nonAppearanceInfo")?.contains("on a hiatus") == true
val author = title.getString("author")
.split("/").joinToString(transform = String::trim)
return manga.copy( return manga.copy(
title = title.getString("name"), title = title.getString("name"),
publicUrl = "/titles/${title.getInt("titleId")}".toAbsoluteUrl(domain), publicUrl = "/titles/${title.getInt("titleId")}".toAbsoluteUrl(domain),
coverUrl = title.getString("portraitImageUrl"), coverUrl = title.getString("portraitImageUrl"),
authors = setOf(author), author = title.getString("author")
.split("/").joinToString(transform = String::trim),
description = buildString { description = buildString {
json.getString("overview").let(::append) json.getString("overview").let(::append)
json.getStringOrNull("viewingPeriodDescription") json.getStringOrNull("viewingPeriodDescription")
@ -171,10 +165,10 @@ internal abstract class MangaPlusParser(
private fun parseChapters(chapterListGroup: JSONArray, language: String): List<MangaChapter> { private fun parseChapters(chapterListGroup: JSONArray, language: String): List<MangaChapter> {
val chapterList = chapterListGroup val chapterList = chapterListGroup
.asTypedList<JSONObject>() .toJSONList()
.flatMap { .flatMap {
it.optJSONArray("firstChapterList")?.asTypedList<JSONObject>().orEmpty() + it.optJSONArray("firstChapterList")?.toJSONList().orEmpty() +
it.optJSONArray("lastChapterList")?.asTypedList<JSONObject>().orEmpty() it.optJSONArray("lastChapterList")?.toJSONList().orEmpty()
} }
return chapterList.mapChapters { _, chapter -> return chapterList.mapChapters { _, chapter ->
@ -184,11 +178,10 @@ internal abstract class MangaPlusParser(
MangaChapter( MangaChapter(
id = generateUid(chapterId), id = generateUid(chapterId),
url = chapterId, url = chapterId,
title = subtitle, name = subtitle,
number = chapter.getString("name") number = chapter.getString("name")
.substringAfter("#") .substringAfter("#")
.toFloatOrNull() ?: -1f, .toIntOrNull() ?: -1,
volume = 0,
uploadDate = chapter.getInt("startTimeStamp") * 1000L, uploadDate = chapter.getInt("startTimeStamp") * 1000L,
branch = when (language) { branch = when (language) {
"PORTUGUESE_BR" -> "Portuguese (Brazil)" "PORTUGUESE_BR" -> "Portuguese (Brazil)"
@ -229,11 +222,14 @@ internal abstract class MangaPlusParser(
return response return response
} }
return response.map { responseBody -> val contentType = response.headers["Content-Type"] ?: "image/jpeg"
val contentType = response.headers["Content-Type"] ?: "image/jpeg"
val image = responseBody.bytes().decodeXorCipher(encryptionKey) val image = requireNotNull(response.body).bytes().decodeXorCipher(encryptionKey)
image.toResponseBody(contentType.toMediaTypeOrNull()) val body = image.toResponseBody(contentType.toMediaTypeOrNull())
}
return response.newBuilder()
.body(body)
.build()
} }
private fun ByteArray.decodeXorCipher(key: String): ByteArray { private fun ByteArray.decodeXorCipher(key: String): ByteArray {
@ -256,7 +252,7 @@ internal abstract class MangaPlusParser(
return checkNotNull(success) { return checkNotNull(success) {
val error = response.getJSONObject("error") val error = response.getJSONObject("error")
val reason = error.getJSONArray("popups") val reason = error.getJSONArray("popups")
.asTypedList<JSONObject>() .toJSONList()
.firstOrNull { it.getStringOrNull("language") == null } .firstOrNull { it.getStringOrNull("language") == null }
if (reason?.getStringOrNull("subject") == "Not Found" && url.contains("manga_viewer")) { if (reason?.getStringOrNull("subject") == "Not Found" && url.contains("manga_viewer")) {
@ -270,63 +266,56 @@ internal abstract class MangaPlusParser(
@MangaSourceParser("MANGAPLUSPARSER_EN", "MANGA Plus English", "en") @MangaSourceParser("MANGAPLUSPARSER_EN", "MANGA Plus English", "en")
class English(context: MangaLoaderContext) : MangaPlusParser( class English(context: MangaLoaderContext) : MangaPlusParser(
context, context,
MangaParserSource.MANGAPLUSPARSER_EN, MangaSource.MANGAPLUSPARSER_EN,
"ENGLISH", "ENGLISH",
) )
@MangaSourceParser("MANGAPLUSPARSER_ES", "MANGA Plus Spanish", "es") @MangaSourceParser("MANGAPLUSPARSER_ES", "MANGA Plus Spanish", "es")
class Spanish(context: MangaLoaderContext) : MangaPlusParser( class Spanish(context: MangaLoaderContext) : MangaPlusParser(
context, context,
MangaParserSource.MANGAPLUSPARSER_ES, MangaSource.MANGAPLUSPARSER_ES,
"SPANISH", "SPANISH",
) )
@MangaSourceParser("MANGAPLUSPARSER_FR", "MANGA Plus French", "fr") @MangaSourceParser("MANGAPLUSPARSER_FR", "MANGA Plus French", "fr")
class French(context: MangaLoaderContext) : MangaPlusParser( class French(context: MangaLoaderContext) : MangaPlusParser(
context, context,
MangaParserSource.MANGAPLUSPARSER_FR, MangaSource.MANGAPLUSPARSER_FR,
"FRENCH", "FRENCH",
) )
@MangaSourceParser("MANGAPLUSPARSER_ID", "MANGA Plus Indonesian", "id") @MangaSourceParser("MANGAPLUSPARSER_ID", "MANGA Plus Indonesian", "id")
class Indonesian(context: MangaLoaderContext) : MangaPlusParser( class Indonesian(context: MangaLoaderContext) : MangaPlusParser(
context, context,
MangaParserSource.MANGAPLUSPARSER_ID, MangaSource.MANGAPLUSPARSER_ID,
"INDONESIAN", "INDONESIAN",
) )
@MangaSourceParser("MANGAPLUSPARSER_PTBR", "MANGA Plus Portuguese (Brazil)", "pt") @MangaSourceParser("MANGAPLUSPARSER_PTBR", "MANGA Plus Portuguese (Brazil)", "pt")
class Portuguese(context: MangaLoaderContext) : MangaPlusParser( class Portuguese(context: MangaLoaderContext) : MangaPlusParser(
context, context,
MangaParserSource.MANGAPLUSPARSER_PTBR, MangaSource.MANGAPLUSPARSER_PTBR,
"PORTUGUESE_BR", "PORTUGUESE_BR",
) )
@MangaSourceParser("MANGAPLUSPARSER_RU", "MANGA Plus Russian", "ru") @MangaSourceParser("MANGAPLUSPARSER_RU", "MANGA Plus Russian", "ru")
class Russian(context: MangaLoaderContext) : MangaPlusParser( class Russian(context: MangaLoaderContext) : MangaPlusParser(
context, context,
MangaParserSource.MANGAPLUSPARSER_RU, MangaSource.MANGAPLUSPARSER_RU,
"RUSSIAN", "RUSSIAN",
) )
@MangaSourceParser("MANGAPLUSPARSER_TH", "MANGA Plus Thai", "th") @MangaSourceParser("MANGAPLUSPARSER_TH", "MANGA Plus Thai", "th")
class Thai(context: MangaLoaderContext) : MangaPlusParser( class Thai(context: MangaLoaderContext) : MangaPlusParser(
context, context,
MangaParserSource.MANGAPLUSPARSER_TH, MangaSource.MANGAPLUSPARSER_TH,
"THAI", "THAI",
) )
@MangaSourceParser("MANGAPLUSPARSER_VI", "MANGA Plus Vietnamese", "vi") @MangaSourceParser("MANGAPLUSPARSER_VI", "MANGA Plus Vietnamese", "vi")
class Vietnamese(context: MangaLoaderContext) : MangaPlusParser( class Vietnamese(context: MangaLoaderContext) : MangaPlusParser(
context, context,
MangaParserSource.MANGAPLUSPARSER_VI, MangaSource.MANGAPLUSPARSER_VI,
"VIETNAMESE", "VIETNAMESE",
) )
@MangaSourceParser("MANGAPLUSPARSER_DE", "MANGA Plus German", "de")
class German(context: MangaLoaderContext) : MangaPlusParser(
context,
MangaParserSource.MANGAPLUSPARSER_DE,
"GERMAN",
)
} }

@ -1,390 +0,0 @@
package org.koitharu.kotatsu.parsers.site.all
import androidx.collection.MutableIntObjectMap
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
import org.koitharu.kotatsu.parsers.bitmap.Rect
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.util.*
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import kotlin.math.min
@MangaSourceParser("MANGAREADERTO", "MangaReader.To")
internal class MangaReaderToParser(context: MangaLoaderContext) :
PagedMangaParser(context, MangaParserSource.MANGAREADERTO, 16),
Interceptor, MangaParserAuthProvider {
override val configKeyDomain = ConfigKey.Domain("mangareader.to")
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val authUrl: String
get() = "https://${domain}/home"
override suspend fun isAuthorized(): Boolean {
return context.cookieJar.getCookies(domain).any {
it.name.contains("connect.sid")
}
}
// It will be easier to connect to a manga page, as the source redirects to a lot of advertising.
override suspend fun getUsername(): String {
val body = webClient.httpGet("https://${domain}/user/profile").parseHtml().body()
return body.getElementById("pro5-name")?.attr("value") ?: body.parseFailed("Cannot find username")
}
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY,
SortOrder.RATING,
SortOrder.UPDATED,
SortOrder.NEWEST,
SortOrder.ALPHABETICAL,
)
val tags = suspendLazy(soft = true) {
val document = webClient.httpGet("https://$domain/filter").parseHtml()
document.select("div.f-genre-item").map {
MangaTag(
title = it.ownText().toTitleCase(sourceLocale),
key = it.attr("data-id"),
source = source,
)
}.associateBy { it.title }
}
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities(
isMultipleTagsSupported = true,
isSearchSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = tags.get().values.toSet(),
availableStates = EnumSet.of(
MangaState.ONGOING,
MangaState.FINISHED,
MangaState.ABANDONED,
MangaState.PAUSED,
MangaState.UPCOMING,
),
)
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val url = "https://$domain".toHttpUrl().newBuilder().apply {
when {
!filter.query.isNullOrEmpty() -> {
addPathSegment("search")
addQueryParameter("keyword", filter.query)
addQueryParameter("page", page.toString())
}
else -> {
addPathSegment("filter")
addQueryParameter("page", page.toString())
addQueryParameter(
name = "sort",
value = when (order) {
SortOrder.POPULARITY -> "most-viewed"
SortOrder.RATING -> "score"
SortOrder.UPDATED -> "latest-updated"
SortOrder.NEWEST -> "release-date"
SortOrder.ALPHABETICAL -> "name-az"
else -> "default"
},
)
addQueryParameter("genres", filter.tags.joinToString(",") { it.key })
addQueryParameter(
name = "status",
value = when (val state = filter.states.oneOrThrowIfMany()) {
MangaState.ONGOING -> "2"
MangaState.FINISHED -> "1"
MangaState.ABANDONED -> "4"
MangaState.PAUSED -> "3"
MangaState.UPCOMING -> "5"
null -> ""
else -> throw IllegalArgumentException("$state not supported")
},
)
}
}
}.build()
val document = webClient.httpGet(url).parseHtml()
return document.select(".manga_list-sbs .manga-poster").map {
val mangaUrl = it.attrAsRelativeUrl("href")
val thumb = it.select("img")
Manga(
id = generateUid(mangaUrl),
url = mangaUrl,
publicUrl = mangaUrl.toAbsoluteUrl(domain),
title = thumb.attr("alt"),
coverUrl = thumb.attr("src"),
source = source,
altTitles = emptySet(),
authors = emptySet(),
contentRating = null,
rating = RATING_UNKNOWN,
state = null,
tags = emptySet(),
)
}
}
override suspend fun getRelatedManga(seed: Manga): List<Manga> {
val document = webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml()
return document.select(".block_area_authors-other .manga_list-sbs .manga-poster, .featured-block-ul .manga-poster")
.map {
val mangaUrl = it.attrAsRelativeUrl("href")
val thumb = it.selectFirstOrThrow("img")
Manga(
id = generateUid(mangaUrl),
url = mangaUrl,
publicUrl = mangaUrl.toAbsoluteUrl(domain),
title = thumb.attr("alt"),
coverUrl = thumb.attrAsAbsoluteUrlOrNull("src"),
source = source,
altTitles = emptySet(),
authors = emptySet(),
contentRating = null,
rating = RATING_UNKNOWN,
state = null,
tags = emptySet(),
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val document = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val availableTags = tags.get()
var isAdult = false
var isSuggestive = false
val author = document.select("div.anisc-info a[href*=/author/]")
.joinToString { it.ownText().replace(", ", " ") }.nullIfEmpty()
return manga.copy(
title = document.selectFirst("h2.manga-name")!!.ownText(),
altTitles = setOfNotNull(document.selectFirst("div.manga-name-or")?.ownTextOrNull()),
rating = document.selectFirst("div.anisc-info .item:contains(score:) > .name")
?.text()?.toFloatOrNull()?.div(10) ?: RATING_UNKNOWN,
coverUrl = document.selectFirst(".manga-poster > img")?.attrAsAbsoluteUrlOrNull("src"),
tags = document.select("div.genres > a[href*=/genre/]").mapNotNullToSet {
val tag = it.ownText()
if (tag == "Hentai") {
isAdult = true
} else if (tag == "Ecchi") {
isSuggestive = true
}
availableTags[tag]
},
contentRating = when {
isAdult -> ContentRating.ADULT
isSuggestive -> ContentRating.SUGGESTIVE
else -> ContentRating.SAFE
},
state = document.selectFirst("div.anisc-info .item:contains(status:) > .name")
?.text()?.let {
when (it) {
"Publishing" -> MangaState.ONGOING
"Finished" -> MangaState.FINISHED
"On Hiatus" -> MangaState.PAUSED
"Discontinued" -> MangaState.ABANDONED
"Not yet published" -> MangaState.UPCOMING
else -> null
}
},
authors = setOfNotNull(author),
description = document.select("div.description").html(),
chapters = parseChapters(document),
source = source,
)
}
private fun parseChapters(document: Document): List<MangaChapter> {
val total =
document.select(".chapters-list-ul > ul > li.chapter-item, .volume-list-ul div.lang-volumes > div.item").size
val chapters = ChaptersListBuilder(total)
document.select(".chapters-list-ul > ul").forEach { ul ->
ul.select("li.chapter-item").reversed().forEach { li ->
val a = li.selectFirst("a")!!
chapters.add(
MangaChapter(
id = generateUid(a.attrAsRelativeUrl("href")),
title = a.attrOrNull("title"),
number = li.attr("data-number").toFloat(),
volume = 0,
url = a.attrAsRelativeUrl("href"),
scanlator = null,
uploadDate = 0L,
branch = createBranchName(ul.id().substringBefore("-chapters"), "Chapters"),
source = source,
),
)
}
}
val numRegex = Regex("""(\d+)""")
document.select(".volume-list-ul div.lang-volumes").forEach { div ->
div.select("div.item > div.manga-poster").reversed().forEach { vol ->
val url = vol.selectFirst("a")!!.attrAsRelativeUrl("href")
val name = vol.selectFirst("span")!!.ownText()
chapters.add(
MangaChapter(
id = generateUid(url),
title = name,
number = numRegex.find(name)?.groupValues?.getOrNull(1)?.toFloatOrNull() ?: 0f,
volume = 0,
url = url,
scanlator = null,
uploadDate = 0L,
branch = createBranchName(div.id().substringBefore("-volumes"), "Volumes"),
source = source,
),
)
}
}
return chapters.toList()
}
private fun createBranchName(lang: String, type: String): String {
val langCode = lang.substringBefore("-")
return Locale(langCode).displayLanguage + " " + type
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val typeAndId = webClient.httpGet(chapter.url.toAbsoluteUrl(domain))
.parseHtml()
.selectFirst("#wrapper")!!.run {
"${attr("data-reading-by")}/${attr("data-reading-id")}"
}
val document = webClient.httpGet("https://$domain/ajax/image/list/$typeAndId?quality=high")
.parseJson()
.getString("html")
.let(Jsoup::parse)
return document.select(".iv-card").map {
val url = it.attr("data-url")
MangaPage(
id = generateUid(url),
url = if (it.hasClass("shuffled")) {
"$url#scrambled"
} else {
url
},
preview = null,
source = source,
)
}
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
if (request.url.fragment != "scrambled") return response
return context.redrawImageResponse(response, ::descramble)
}
private val memo = MutableIntObjectMap<IntArray>()
private fun descramble(bitmap: Bitmap): Bitmap = synchronized(memo) {
val width = bitmap.width
val height = bitmap.height
val result = context.createBitmap(width, height)
val pieces = ArrayList<Piece>()
for (y in 0 until height step PIECE_SIZE) {
for (x in 0 until width step PIECE_SIZE) {
val w = min(PIECE_SIZE, width - x)
val h = min(PIECE_SIZE, height - y)
pieces.add(Piece(x, y, w, h))
}
}
val groups = pieces.groupBy { it.w shl 16 or it.h }
for (group in groups.values) {
val size = group.size
val permutation = memo.getOrPut(size) {
val random = SeedRandom("staystay")
// https://github.com/webcaetano/shuffle-seed
val indices = (0 until size).toMutableList()
IntArray(size) { indices.removeAt((random.nextDouble() * indices.size).toInt()) }
}
for ((i, original) in permutation.withIndex()) {
val src = group[i]
val dst = group[original]
val srcRect = Rect(src.x, src.y, src.x + src.w, src.y + src.h)
val dstRect = Rect(dst.x, dst.y, dst.x + dst.w, dst.y + dst.h)
result.drawBitmap(bitmap, srcRect, dstRect)
}
}
return result
}
private class Piece(val x: Int, val y: Int, val w: Int, val h: Int)
// https://github.com/davidbau/seedrandom
private class SeedRandom(key: String) {
private val input = ByteArray(RC4_WIDTH)
private val buffer = ByteArray(RC4_WIDTH)
private var pos = RC4_WIDTH
private val rc4 = Cipher.getInstance("RC4").apply {
init(Cipher.ENCRYPT_MODE, SecretKeySpec(key.toByteArray(), "RC4"))
update(input, 0, RC4_WIDTH, buffer) // RC4-drop[256]
}
fun nextDouble(): Double {
var num = nextByte()
var exp = 8
while (num < 1L shl 52) {
num = num shl 8 or nextByte()
exp += 8
}
while (num >= 1L shl 53) {
num = num ushr 1
exp--
}
return Math.scalb(num.toDouble(), -exp)
}
private fun nextByte(): Long {
if (pos == RC4_WIDTH) {
rc4.update(input, 0, RC4_WIDTH, buffer)
pos = 0
}
return buffer[pos++].toLong() and 0xFF
}
}
}
private const val RC4_WIDTH = 256
private const val PIECE_SIZE = 200

@ -1,228 +0,0 @@
package org.koitharu.kotatsu.parsers.site.all
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@MangaSourceParser("MANHWA210", "Manhwa210", type = ContentType.MANHWA)
internal class Manhwa210(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.MANHWA210, 60) {
override val configKeyDomain = ConfigKey.Domain("manhwa210.com")
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.ALPHABETICAL,
SortOrder.ALPHABETICAL_DESC,
SortOrder.UPDATED,
SortOrder.NEWEST,
SortOrder.POPULARITY,
)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities(
isSearchSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = availableTags(),
availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED),
)
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val url = buildString {
append("https://")
append(domain)
when {
!filter.query.isNullOrEmpty() -> {
append("/search")
append("?filter[name]=")
append(filter.query.urlEncoded())
if (page > 1) {
append("&page=")
append(page)
}
append("&sort=")
append(
when (order) {
SortOrder.POPULARITY -> "-views"
SortOrder.UPDATED -> "-updated_at"
SortOrder.NEWEST -> "-created_at"
SortOrder.ALPHABETICAL -> "name"
SortOrder.ALPHABETICAL_DESC -> "-name"
else -> "-updated_at"
},
)
}
filter.tags.isNotEmpty() -> {
val tag = filter.tags.first()
append("/genre/")
append(tag.key)
append("?page=")
append(page)
}
else -> {
append("/list")
append("?sort=")
append(
when (order) {
SortOrder.POPULARITY -> "-views"
SortOrder.UPDATED -> "-updated_at"
SortOrder.NEWEST -> "-created_at"
SortOrder.ALPHABETICAL -> "name"
SortOrder.ALPHABETICAL_DESC -> "-name"
else -> "-updated_at"
},
)
append("&page=")
append(page)
}
}
if (filter.query.isNullOrEmpty()) {
append("&sort=")
when (order) {
SortOrder.POPULARITY -> append("-views")
SortOrder.UPDATED -> append("-updated_at")
SortOrder.NEWEST -> append("-created_at")
SortOrder.ALPHABETICAL -> append("name")
SortOrder.ALPHABETICAL_DESC -> append("-name")
else -> append("-updated_at")
}
}
if (filter.states.isNotEmpty()) {
append("&filter[status]=")
filter.states.forEach {
append(
when (it) {
MangaState.ONGOING -> "2,"
MangaState.FINISHED -> "1,"
else -> "1,2"
},
)
}
}
}
val doc = webClient.httpGet(url).parseHtml()
return doc.select("div.grid div.relative").map { div ->
val href = div.selectFirst("a[href^=/manga/]")?.attrOrNull("href")
?: div.parseFailed("Cant find manga image!")
val coverUrl = div.selectFirst("div.cover")?.attr("style")
?.substringAfter("url('")?.substringBefore("')")
Manga(
id = generateUid(href),
title = div.select("div.p-2 a.text-ellipsis").text(),
altTitles = emptySet(),
url = href,
publicUrl = href.toAbsoluteUrl(domain),
rating = RATING_UNKNOWN,
contentRating = ContentRating.ADULT,
coverUrl = coverUrl.orEmpty(),
tags = setOf(),
state = null,
authors = emptySet(),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val author = root.selectFirst("div.mt-2:contains(Artist) span a")?.textOrNull()
return manga.copy(
altTitles = setOfNotNull(root.selectLast("div.grow div:contains(Alt name) span")?.textOrNull()),
state = when (root.selectFirst("div.mt-2:contains(Status) span.text-blue-500")?.text()) {
"Ongoing" -> MangaState.ONGOING
"Completed" -> MangaState.FINISHED
else -> null
},
tags = root.select("div.mt-2:contains(Genres) a.bg-gray-500").mapToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix('/').substringAfterLast('/'),
title = a.text(),
source = source,
)
},
authors = setOfNotNull(author),
description = root.selectFirst("meta[name=description]")?.attrOrNull("content"),
chapters = root.select("div.justify-between ul.overflow-y-auto.overflow-x-hidden a")
.mapChapters(reversed = true) { i, a ->
val href = a.attrAsRelativeUrl("href")
val name = a.selectFirst("span.text-ellipsis")?.text().orEmpty()
val dateText = a.parent()?.selectFirst("span.timeago")?.attr("datetime").orEmpty()
MangaChapter(
id = generateUid(href),
title = name,
number = i.toFloat(),
volume = 0,
url = href,
scanlator = null,
uploadDate = parseDateTime(dateText),
branch = null,
source = source,
)
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml()
return doc.select("div.text-center img.lazy").mapNotNull { img ->
val url = img.requireSrc()
MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = source,
)
}
}
private fun parseDateTime(dateStr: String): Long = runCatching {
val parts = dateStr.split(' ')
val dateParts = parts[0].split('-')
val timeParts = parts[1].split(':')
val calendar = Calendar.getInstance()
calendar.set(
dateParts[0].toInt(),
dateParts[1].toInt() - 1,
dateParts[2].toInt(),
timeParts[0].toInt(),
timeParts[1].toInt(),
timeParts[2].toInt(),
)
calendar.timeInMillis
}.getOrDefault(0L)
private suspend fun availableTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://$domain").parseHtml()
return doc.select("ul.grid.grid-cols-2 a").mapToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix('/').substringAfterLast('/'),
title = a.text(),
source = source,
)
}
}
}

@ -1,148 +0,0 @@
package org.koitharu.kotatsu.parsers.site.all
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@MangaSourceParser("MISSKON", "MissKon", type = ContentType.OTHER)
internal class Misskon(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.MISSKON, 24) {
override val configKeyDomain = ConfigKey.Domain("misskon.com")
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.POPULARITY
)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities( isSearchSupported = true )
override suspend fun getFilterOptions() = MangaListFilterOptions()
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val url = buildString {
append("https://")
append(domain)
when {
!filter.query.isNullOrEmpty() -> {
append("/page/$page/")
append("?s=")
append(filter.query.urlEncoded())
}
order == SortOrder.POPULARITY -> {
append("/top3/")
}
else -> {
append("/page/$page")
}
}
}
val doc = webClient.httpGet(url).parseHtml()
return doc.select("article.item-list").map { article ->
val titleEl = article.selectFirst(".post-box-title")!!
val href = titleEl.selectFirst("a")?.attrAsRelativeUrl("href")
?: article.parseFailed("Cannot find manga link")
Manga(
id = generateUid(href),
title = titleEl.text(),
altTitles = emptySet(),
url = href,
publicUrl = href.toAbsoluteUrl(domain),
rating = RATING_UNKNOWN,
contentRating = ContentRating.ADULT,
coverUrl = article.selectFirst(".post-thumbnail img")?.absUrl("data-src").orEmpty(),
tags = setOf(),
state = null,
authors = emptySet(),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val postInnerEl = doc.selectFirst("article > .post-inner")!!
return manga.copy(
tags = postInnerEl.select(".post-tag > a").mapToSet { a ->
MangaTag(
key = a.text().lowercase(),
title = a.text(),
source = source
)
},
chapters = listOf(
MangaChapter(
id = manga.id,
title = "Oneshot", // 1 album, idk
number = 1f,
volume = 0,
url = manga.url,
scanlator = null,
uploadDate = 0L,
branch = null,
source = source
)
)
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
val basePageUrl = doc.selectFirst("link[rel=canonical]")?.absUrl("href")
?: chapter.url.toAbsoluteUrl(domain)
val pages = mutableListOf<MangaPage>()
val pageLinks = doc.select("div.post-inner div.page-link:nth-child(1) .post-page-numbers")
if (pageLinks.isEmpty()) {
// Single page gallery
return doc.select("div.post-inner > div.entry > p > img")
.mapNotNull { img -> img.absUrl("data-src") }
.mapIndexed { i, url ->
MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = source
)
}
}
// Multi-page gallery
pageLinks.forEachIndexed { index, pageEl ->
val pageDoc = when (index) {
0 -> doc
else -> {
val url = "$basePageUrl${pageEl.text()}/"
webClient.httpGet(url).parseHtml()
}
}
pages.addAll(
pageDoc.select("div.post-inner > div.entry > p > img")
.mapNotNull { img -> img.absUrl("data-src") }
.map { url ->
MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = source
)
}
)
}
return pages
}
}

@ -1,229 +0,0 @@
package org.koitharu.kotatsu.parsers.site.all
import okhttp3.Headers
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@MangaSourceParser("MULTPORN", "Multporn")
internal class Multporn(context: MangaLoaderContext) :
PagedMangaParser(context, MangaParserSource.MULTPORN, 42) {
override val configKeyDomain = ConfigKey.Domain("multporn.net")
override fun getRequestHeaders(): Headers = Headers.Builder()
.add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.122 Safari/537.36")
.add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.build()
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST,
SortOrder.NEWEST_ASC,
SortOrder.UPDATED,
SortOrder.UPDATED_ASC,
)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities(
isSearchSupported = true,
)
init {
setFirstPage(0)
}
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableLocales = setOf(
Locale("en"),
Locale("de"),
Locale("ru"),
Locale("zh"),
Locale("es"),
),
availableContentTypes = EnumSet.of(
ContentType.COMICS,
ContentType.HENTAI,
),
)
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val url = buildString {
append("https://")
append(domain)
when {
!filter.query.isNullOrEmpty() -> {
append("/search?search_api_views_fulltext=")
val encodedQuery = filter.query.splitByWhitespace().joinToString(separator = "+") { part ->
part.urlEncoded()
}
append(encodedQuery)
append("&undefined=Search")
append("&page=$page")
}
filter.tags.isNotEmpty() -> {
val tag = filter.tags.first()
append("/category/")
append(tag.key)
append("?sort_by=")
append(
when (order) {
SortOrder.NEWEST -> "created"
else -> "title" // default
}
)
append("&page=0,")
append(page)
}
else -> {
append("/new")
append("?type=")
if (filter.types.isNotEmpty()) {
filter.types.oneOrThrowIfMany()?.let {
append(
when (it) {
ContentType.COMICS -> "1"
ContentType.HENTAI -> "2"
else -> "All" // all
},
)
}
} else append("All")
filter.locale?.let {
append("&language=")
append(
when (it) {
Locale("en") -> "1"
Locale("de") -> "2"
Locale("ru") -> "3"
Locale("zh") -> "4"
Locale("es") -> "5"
else -> "All"
}
)
}
append("&field_user_discription_value=All")
append("&sort_by=")
append(
when (order) {
SortOrder.NEWEST -> "created&sort_order=DESC"
SortOrder.NEWEST_ASC -> "created&sort_order=ASC"
SortOrder.UPDATED -> "changed&sort_order=DESC"
SortOrder.UPDATED_ASC -> "changed&sort_order=ASC"
else -> "created&sort_order=DESC" // default
}
)
append("&undefined=Apply")
append("&page=$page")
}
}
}
val doc = webClient.httpGet(url).parseHtml()
return doc.select(".masonry-item").map { div ->
val href = div.selectFirstOrThrow(".views-field-title a").attrAsRelativeUrl("href")
val coverUrl = div.selectFirstOrThrow(".views-field img").requireSrc()
Manga(
id = generateUid(href),
title = div.select(".views-field-title").text(),
altTitles = emptySet(),
url = href,
publicUrl = href.toAbsoluteUrl(domain),
rating = RATING_UNKNOWN,
contentRating = ContentRating.ADULT,
coverUrl = coverUrl,
tags = emptySet(),
state = null,
authors = emptySet(),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val authors = (doc.select(".field:has(.field-label:contains(Author:)) .links a").map { it.text() } +
parseUnlabelledAuthorNames(doc)).distinct()
val tags = listOf("Tags", "Section", "Characters")
.flatMap { type ->
doc.select(".field:has(.field-label:contains($type:)) .links a").map { it.text() }
}
.distinct()
.map { tag ->
MangaTag(
title = tag,
key = tag.lowercase().replace(" ", "_"),
source = source,
)
}.toSet()
val isOngoing = doc.select(".field .links a").any { it.text() == "Ongoings" }
return manga.copy(
authors = authors.toSet(),
tags = tags,
description = buildString {
append("Pages: ")
append(doc.select(".jb-image img").size)
append("\n\n")
doc.select(".field:has(.field-label:contains(Section:)) .links a").joinTo(this, prefix = "Section: ") { it.text() }
doc.select(".field:has(.field-label:contains(Characters:)) .links a").joinTo(this, prefix = "\n\nCharacters: ") { it.text() }
},
state = if (isOngoing) MangaState.ONGOING else MangaState.FINISHED,
chapters = listOf(
MangaChapter(
id = generateUid(manga.url),
title = "Oneshot",
number = 1f,
volume = 0,
url = manga.url,
scanlator = null,
uploadDate = 0L,
branch = null,
source = source,
)
),
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
return doc.select(".jb-image img").mapIndexed { i, img ->
val url = img.attrAsAbsoluteUrl("src")
.replace("/styles/juicebox_2k/public", "")
.substringBefore("?")
MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = source,
)
}
}
private fun parseUnlabelledAuthorNames(document: org.jsoup.nodes.Document): List<String> {
val authorClasses = listOf(
"field-name-field-author",
"field-name-field-authors-gr",
"field-name-field-img-group",
"field-name-field-hentai-img-group",
"field-name-field-rule-63-section"
)
return authorClasses.flatMap { className ->
document.select(".$className a").map { it.text().trim() }
}
}
}

@ -1,451 +0,0 @@
package org.koitharu.kotatsu.parsers.site.all
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.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.attrAsRelativeUrl
import org.koitharu.kotatsu.parsers.util.generateUid
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.parseHtml
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import org.koitharu.kotatsu.parsers.util.urlEncoded
import java.text.SimpleDateFormat
import java.util.EnumSet
import java.util.Locale
import java.util.regex.Pattern
@MangaSourceParser("MYREADINGMANGA", "MyReadingManga", type = ContentType.HENTAI)
internal class MyReadingManga(context: MangaLoaderContext) :
PagedMangaParser(context, MangaParserSource.MYREADINGMANGA, 18) {
override val configKeyDomain = ConfigKey.Domain("myreadingmanga.info")
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities(
isSearchSupported = true,
isOriginalLocaleSupported = true,
)
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = fetchTags(),
availableStates = EnumSet.of(
MangaState.ONGOING,
MangaState.FINISHED,
),
availableContentRating = EnumSet.of(ContentRating.ADULT),
availableLocales = setOf(
Locale.ENGLISH,
Locale.FRENCH,
Locale.JAPANESE,
Locale.CHINESE,
Locale.GERMAN,
Locale.ITALIAN,
Locale.KOREAN,
Locale.TRADITIONAL_CHINESE,
Locale("es"), // Spanish
Locale("pt"), // Portuguese
Locale("ru"), // Russian
Locale("tr"), // Turkish
Locale("vi"), // Vietnamese
Locale("ar"), // Arabic
Locale("id"), // Indonesian (Bahasa)
Locale("th"), // Thai
Locale("pl"), // Polish
Locale("sv"), // Swedish
Locale("nl"), // Dutch (Flemish Dutch)
Locale("hu"), // Hungarian
Locale("hi"), // Hindi
Locale("he"), // Hebrew
Locale("el"), // Greek
Locale("fi"), // Finnish
Locale("fil"), // Filipino
Locale("da"), // Danish
Locale("cs"), // Czech
Locale("hr"), // Croatian
Locale("bg"), // Bulgarian
Locale("zh", "HK"), // Cantonese
Locale("fa"), // Persian
Locale("sk"), // Slovak
Locale("ro"), // Romanian
Locale("no"), // Norwegian
Locale("ms"), // Malay
Locale("lt"), // Lithuanian
),
)
private fun getLanguageSlug(locale: Locale?): String? {
return when {
locale?.language == "fr" -> "french"
locale?.language == "ja" -> "jp"
locale?.language == "zh" && locale.country == "TW" -> "traditional-chinese"
locale?.language == "zh" && locale.country == "HK" -> "cantonese"
locale?.language == "zh" -> "chinese"
locale?.language == "de" -> "german"
locale?.language == "it" -> "italian"
locale?.language == "ko" -> "korean"
locale?.language == "es" -> "spanish"
locale?.language == "pt" -> "portuguese"
locale?.language == "ru" -> "russian"
locale?.language == "tr" -> "turkish"
locale?.language == "vi" -> "vietnamese"
locale?.language == "ar" -> "arabic"
locale?.language == "id" -> "bahasa"
locale?.language == "th" -> "thai"
locale?.language == "pl" -> "polish"
locale?.language == "sv" -> "swedish"
locale?.language == "nl" -> "flemish-dutch"
locale?.language == "hu" -> "hungarian"
locale?.language == "hi" -> "hindi"
locale?.language == "he" -> "hebrew"
locale?.language == "el" -> "greek"
locale?.language == "fi" -> "finnish"
locale?.language == "fil" -> "filipino"
locale?.language == "da" -> "danish"
locale?.language == "cs" -> "czech"
locale?.language == "hr" -> "croatian"
locale?.language == "bg" -> "bulgarian"
locale?.language == "fa" -> "persian"
locale?.language == "sk" -> "slovak"
locale?.language == "ro" -> "romanian"
locale?.language == "no" -> "norwegian-bokmal"
locale?.language == "ms" -> "malay"
locale?.language == "lt" -> "lithuanian"
else -> null //all
}
}
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val url = buildString {
append("https://")
append(domain)
// Add language path if specified
val langSlug = getLanguageSlug(filter.locale)
if (langSlug != null) {
append("/lang/")
append(langSlug)
}
when {
!filter.query.isNullOrEmpty() -> {
// Search with language: /lang/french/page/2/?s=example
if (page > 1) {
append("/page/")
append(page)
}
append("/?s=")
append(filter.query.urlEncoded())
}
filter.tags.isNotEmpty() -> {
// Genre filtering doesn't work with language, so we ignore language for genre
if (langSlug == null) {
append("/genre/")
append(filter.tags.first().key)
append("/page/")
append(page)
append("/")
} else {
// If both language and genre are selected, just use language
append("/page/")
append(page)
append("/")
}
}
filter.states.isNotEmpty() -> {
// Status filtering doesn't work with language either
if (langSlug == null) {
append("/status/")
append(
when (filter.states.first()) {
MangaState.ONGOING -> "ongoing"
MangaState.FINISHED -> "completed"
else -> "ongoing"
},
)
append("/page/")
append(page)
append("/")
} else {
// If both language and status are selected, just use language
append("/page/")
append(page)
append("/")
}
}
else -> {
// Regular browsing with or without language
append("/page/")
append(page)
append("/")
}
}
}
val doc = webClient.httpGet(url).parseHtml()
return parseMangaList(doc)
}
private fun parseMangaList(doc: Document): List<Manga> {
return doc.select("div.content-archive article.post:not(.category-video)").mapNotNull { element ->
val titleElement = element.selectFirst("h2.entry-title a") ?: return@mapNotNull null
val thumbnailElement = element.selectFirst("a.entry-image-link img")
Manga(
id = generateUid(titleElement.attr("href")),
title = titleElement.text().replace(titleRegex.toRegex(), "").substringBeforeLast("(").trim(),
altTitles = emptySet(),
url = titleElement.attrAsRelativeUrl("href"),
publicUrl = titleElement.absUrl("href"),
rating = RATING_UNKNOWN,
contentRating = ContentRating.ADULT,
coverUrl = findImageSrc(thumbnailElement),
tags = emptySet(),
state = null,
authors = emptySet(),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val title = doc.selectFirst("h1.entry-title")?.text() ?: manga.title
val altTitles = mutableSetOf<String>()
val altTitleElement = doc.selectFirst("p.alt-title-class")
if (altTitleElement != null) {
var nextElement = altTitleElement.nextElementSibling()
while (nextElement != null && nextElement.tagName() == "p" &&
!nextElement.hasClass("info-class") && !nextElement.hasClass("chapter-class")
) {
val altTitle = nextElement.text().trim()
if (altTitle.isNotEmpty()) {
altTitles.add(altTitle)
}
nextElement = nextElement.nextElementSibling()
}
}
var description = ""
val descriptionElement = doc.selectFirst("p.info-class")
if (descriptionElement != null) {
var nextElement = descriptionElement.nextElementSibling()
val descParts = mutableListOf<String>()
while (nextElement != null && nextElement.tagName() == "p" &&
!nextElement.hasClass("chapter-class") && !nextElement.hasClass("alt-title-class")
) {
val text = nextElement.text()
if (text.isNotEmpty()) {
descParts.add(text)
}
nextElement = nextElement.nextElementSibling()
}
description = descParts.joinToString("\n\n")
}
if (description.isEmpty()) {
description = doc.select("div.entry-content p strong")
.joinToString("\n") { it.text() }
.trim()
.ifEmpty { title }
}
val authorFromTitle = title.substringAfter("[").substringBefore("]").trim()
val authorFromTag = doc.select("span.entry-tags a[href*='/tag/']")
.firstOrNull { it.text().contains("(") && it.text().contains(")") }
?.text()?.trim()
val author = authorFromTag ?: authorFromTitle
val genres = mutableSetOf<MangaTag>()
doc.select("span.entry-terms:has(span:contains(Genres)) a").forEach {
genres.add(
MangaTag(
title = it.text(),
key = it.attr("href").substringAfterLast("/genre/").substringBefore("/"),
source = source,
),
)
}
val state = when (doc.select("a[href*=status]").firstOrNull()?.text()) {
"Ongoing" -> MangaState.ONGOING
"Completed" -> MangaState.FINISHED
else -> null
}
val chapters = parseChapters(doc)
return manga.copy(
altTitles = altTitles,
description = description,
tags = genres,
state = state,
authors = setOfNotNull(author.takeIf { it.isNotEmpty() && it != title }),
chapters = chapters,
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
val images = doc.select("div.entry-content img.img-myreadingmanga, div.entry-content div > img")
.filter { element ->
val src = findImageSrc(element)
src != null && !src.contains("GH-") && !src.contains("nucarnival") &&
!src.contains("/wp-content/uploads/202") // Exclude old uploads that might be ads
}
.mapNotNull { findImageSrc(it) }
.distinct()
return images.mapIndexed { index, url ->
MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = source,
)
}
}
private suspend fun fetchTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://$domain/").parseHtml()
return doc.select("h4.widget-title.widgettitle:contains(Genres) + .tagcloud a")
.mapToSet { element ->
MangaTag(
title = element.text().substringBefore(" ("),
key = element.attr("href").trimEnd('/').substringAfterLast('/'),
source = source,
)
}
}
private val titleRegex = Pattern.compile("""\[[^]]*]""")
private val imgRegex = Pattern.compile("""\.(jpg|png|jpeg|webp)""")
private fun findImageSrc(element: Element?): String? {
element ?: return null
return when {
element.hasAttr("data-src") && imgRegex.matcher(element.attr("data-src")).find() ->
element.absUrl("data-src")
element.hasAttr("data-cfsrc") && imgRegex.matcher(element.attr("data-cfsrc")).find() ->
element.absUrl("data-cfsrc")
element.hasAttr("src") && imgRegex.matcher(element.attr("src")).find() ->
element.absUrl("src")
element.hasAttr("data-lazy-src") ->
element.absUrl("data-lazy-src")
else -> null
}
}
private fun parseChapters(document: Document): List<MangaChapter> {
val chapters = mutableListOf<MangaChapter>()
val mangaUrl = document.baseUri().removeSuffix("/")
val date = parseDate(document.select("time.entry-time").text())
// Look for chapter information
val chapterClass = document.selectFirst("div.chapter-class")
// Check if there's a chapter title after the chapter-class div
var chapterTitle: String? = null
if (chapterClass != null) {
var nextElement = chapterClass.nextElementSibling()
while (nextElement != null && nextElement.tagName() != "div") {
if (nextElement.tagName() == "p" && nextElement.text().contains("Chapter", ignoreCase = true)) {
chapterTitle = nextElement.text().trim()
break
}
nextElement = nextElement.nextElementSibling()
}
}
// Check for pagination
val paginationInContent =
document.select("div.entry-pagination a.page-numbers, div.chapter-class .entry-pagination a.page-numbers")
.mapNotNull { it.text().toIntOrNull() }
.maxOrNull()
if (paginationInContent != null && paginationInContent > 1) {
// Multi-page manga with chapters
for (i in 1..paginationInContent) {
val title = when {
chapterTitle != null && i == 1 -> chapterTitle
chapterTitle != null -> chapterTitle.replace("1", i.toString())
else -> "Chapter $i"
}
chapters.add(
MangaChapter(
id = generateUid("$mangaUrl/$i"),
title = title,
number = i.toFloat(),
url = if (i == 1) mangaUrl else "$mangaUrl/$i/",
uploadDate = date,
source = source,
scanlator = null,
branch = null,
volume = 0,
),
)
}
} else {
// Single page manga or no pagination found
chapters.add(
MangaChapter(
id = generateUid(mangaUrl),
title = chapterTitle ?: "Complete",
number = 1f,
url = mangaUrl,
uploadDate = date,
source = source,
scanlator = null,
branch = null,
volume = 0,
),
)
}
return chapters
}
private fun parseDate(date: String): Long {
return try {
SimpleDateFormat("MMMM dd, yyyy", Locale.US).parse(date)?.time ?: 0
} catch (_: Exception) {
0L
}
}
}

@ -8,8 +8,8 @@ import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -17,22 +17,17 @@ import java.util.*
internal abstract class NineMangaParser( internal abstract class NineMangaParser(
context: MangaLoaderContext, context: MangaLoaderContext,
source: MangaParserSource, source: MangaSource,
defaultDomain: String, defaultDomain: String,
) : PagedMangaParser(context, source, pageSize = 26), Interceptor { ) : PagedMangaParser(context, source, pageSize = 26), Interceptor {
override val configKeyDomain = ConfigKey.Domain(defaultDomain) override val configKeyDomain = ConfigKey.Domain(defaultDomain)
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
init { init {
context.cookieJar.insertCookies(domain, "ninemanga_template_desk=yes") context.cookieJar.insertCookies(domain, "ninemanga_template_desk=yes")
} }
override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() override val headers = super.headers.newBuilder()
.add("Accept-Language", "en-US;q=0.7,en;q=0.3") .add("Accept-Language", "en-US;q=0.7,en;q=0.3")
.build() .build()
@ -40,22 +35,13 @@ internal abstract class NineMangaParser(
SortOrder.POPULARITY, SortOrder.POPULARITY,
) )
override val filterCapabilities: MangaListFilterCapabilities override val availableStates: Set<MangaState> = EnumSet.of(
get() = MangaListFilterCapabilities( MangaState.ONGOING,
isMultipleTagsSupported = true, MangaState.FINISHED,
isTagsExclusionSupported = true,
isSearchWithFiltersSupported = true,
isSearchSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = getOrCreateTagMap().values.toSet(),
availableStates = EnumSet.of(
MangaState.ONGOING,
MangaState.FINISHED,
),
) )
override val isTagsExclusionSupported: Boolean = true
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
val newRequest = if (request.url.host == domain) { val newRequest = if (request.url.host == domain) {
@ -66,46 +52,56 @@ internal abstract class NineMangaParser(
return chain.proceed(newRequest) return chain.proceed(newRequest)
} }
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> { override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
when (filter) {
if (filter.tags.isNotEmpty() || filter.tagsExclude.isNotEmpty() || filter.states.isNotEmpty() || !filter.query.isNullOrEmpty()) { is MangaListFilter.Search -> {
append("/search/") append("/search/?name_sel=&wd=")
append("?page=")
append(page.toString())
filter.query?.let {
append("&name_sel=contain&wd=")
append(filter.query.urlEncoded()) append(filter.query.urlEncoded())
append("&page=")
append(page)
append(".html")
} }
append("&category_id=") is MangaListFilter.Advanced -> {
append(filter.tags.joinToString(separator = ",") { it.key })
if (filter.tags.isNotEmpty() || filter.tagsExclude.isNotEmpty() || filter.states.isNotEmpty()) {
append("&out_category_id=") append("/search/?category_id=")
append(filter.tagsExclude.joinToString(separator = ",") { it.key }) append(filter.tags.joinToString(separator = ",") { it.key })
filter.states.oneOrThrowIfMany()?.let { append("&out_category_id=")
append("&completed_series=") append(filter.tagsExclude.joinToString(separator = ",") { it.key })
when (it) {
MangaState.ONGOING -> append("no") filter.states.oneOrThrowIfMany()?.let {
MangaState.FINISHED -> append("yes") append("&completed_series=")
else -> append("either") when (it) {
MangaState.ONGOING -> append("no")
MangaState.FINISHED -> append("yes")
else -> append("either")
}
}
append("&page=")
} else {
append("/category/index_")
} }
append(page.toString())
append(".html")
} }
} else { null -> {
append("/category/index_") append("/category/index_")
append(page.toString()) append(page)
append(".html")
}
} }
} }
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().selectFirstOrThrow("ul.direlist") val root = doc.body().selectFirst("ul.direlist") ?: doc.parseFailed("Cannot find root")
val baseHost = root.baseUri().toHttpUrl().host val baseHost = root.baseUri().toHttpUrl().host
return root.select("li").map { node -> return root.select("li").map { node ->
val href = node.selectFirstOrThrow("a").attrAsAbsoluteUrl("href") val href = node.selectFirst("a")?.absUrl("href") ?: node.parseFailed("Link not found")
val relUrl = href.toRelativeUrl(baseHost) val relUrl = href.toRelativeUrl(baseHost)
val dd = node.selectFirst("dd") val dd = node.selectFirst("dd")
Manga( Manga(
@ -113,11 +109,11 @@ internal abstract class NineMangaParser(
url = relUrl, url = relUrl,
publicUrl = href, publicUrl = href,
title = dd?.selectFirst("a.bookname")?.text()?.toCamelCase().orEmpty(), title = dd?.selectFirst("a.bookname")?.text()?.toCamelCase().orEmpty(),
altTitles = emptySet(), altTitle = null,
coverUrl = node.selectFirst("img")?.src(), coverUrl = node.selectFirst("img")?.absUrl("src").orEmpty(),
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
authors = emptySet(), author = null,
contentRating = null, isNsfw = false,
tags = emptySet(), tags = emptySet(),
state = null, state = null,
source = source, source = source,
@ -135,24 +131,21 @@ internal abstract class NineMangaParser(
val tagMap = getOrCreateTagMap() val tagMap = getOrCreateTagMap()
val selectTag = infoRoot.getElementsByAttributeValue("itemprop", "genre").first()?.select("a") val selectTag = infoRoot.getElementsByAttributeValue("itemprop", "genre").first()?.select("a")
val tags = selectTag?.mapNotNullToSet { tagMap[it.text()] } val tags = selectTag?.mapNotNullToSet { tagMap[it.text()] }
val author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.textOrNull()
return manga.copy( return manga.copy(
title = root.selectFirst("h1[itemprop=name]")?.textOrNull()?.removeSuffix("Manga")?.trimEnd()
?: manga.title,
tags = tags.orEmpty(), tags = tags.orEmpty(),
authors = setOfNotNull(author), author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(),
state = parseStatus(infoRoot.select("li a.red").text()), state = parseStatus(infoRoot.select("li a.red").text()),
description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()?.html() description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()?.html()
?.substringAfter("</b>"), ?.substringAfter("</b>"),
chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li") chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li")
?.mapChapters(reversed = true) { i, li -> ?.mapChapters(reversed = true) { i, li ->
val a = li.selectFirstOrThrow("a.chapter_list_a") val a = li.selectFirst("a.chapter_list_a")
val href = a.attrAsRelativeUrl("href").replace("%20", " ") val href =
a?.attrAsRelativeUrlOrNull("href")?.replace("%20", " ") ?: li.parseFailed("Link not found")
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
title = a.textOrNull(), name = a.text(),
number = i + 1f, number = i + 1,
volume = 0,
url = href, url = href,
uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()), uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()),
source = source, source = source,
@ -165,7 +158,7 @@ internal abstract class NineMangaParser(
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
return doc.body().requireElementById("page").select("option").map { option -> return doc.body().getElementById("page")?.select("option")?.map { option ->
val url = option.attr("value") val url = option.attr("value")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
@ -173,18 +166,22 @@ internal abstract class NineMangaParser(
preview = null, preview = null,
source = source, source = source,
) )
} } ?: doc.parseFailed("Pages list not found")
} }
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body() val root = doc.body()
return root.selectFirstOrThrow("a.pic_download").attrAsAbsoluteUrl("href") return root.selectFirst("a.pic_download")?.absUrl("href") ?: doc.parseFailed("Page image not found")
} }
private var tagCache: ArrayMap<String, MangaTag>? = null private var tagCache: ArrayMap<String, MangaTag>? = null
private val mutex = Mutex() private val mutex = Mutex()
override suspend fun getAvailableTags(): Set<MangaTag> {
return getOrCreateTagMap().values.toSet()
}
private suspend fun getOrCreateTagMap(): Map<String, MangaTag> = mutex.withLock { private suspend fun getOrCreateTagMap(): Map<String, MangaTag> = mutex.withLock {
tagCache?.let { return@withLock it } tagCache?.let { return@withLock it }
val tagMap = ArrayMap<String, MangaTag>() val tagMap = ArrayMap<String, MangaTag>()
@ -233,7 +230,7 @@ internal abstract class NineMangaParser(
if (dateWords.size == 3) { if (dateWords.size == 3) {
if (dateWords[1].contains(",")) { if (dateWords[1].contains(",")) {
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parseSafe(date) SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).tryParse(date)
} else { } else {
val timeAgo = Integer.parseInt(dateWords[0]) val timeAgo = Integer.parseInt(dateWords[0])
return Calendar.getInstance().apply { return Calendar.getInstance().apply {
@ -269,49 +266,49 @@ internal abstract class NineMangaParser(
@MangaSourceParser("NINEMANGA_EN", "NineManga English", "en") @MangaSourceParser("NINEMANGA_EN", "NineManga English", "en")
class English(context: MangaLoaderContext) : NineMangaParser( class English(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaParserSource.NINEMANGA_EN, MangaSource.NINEMANGA_EN,
"www.ninemanga.com", "www.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_ES", "NineManga Español", "es") @MangaSourceParser("NINEMANGA_ES", "NineManga Español", "es")
class Spanish(context: MangaLoaderContext) : NineMangaParser( class Spanish(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaParserSource.NINEMANGA_ES, MangaSource.NINEMANGA_ES,
"es.ninemanga.com", "es.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_RU", "NineManga Русский", "ru") @MangaSourceParser("NINEMANGA_RU", "NineManga Русский", "ru")
class Russian(context: MangaLoaderContext) : NineMangaParser( class Russian(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaParserSource.NINEMANGA_RU, MangaSource.NINEMANGA_RU,
"ru.ninemanga.com", "ru.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_DE", "NineManga Deutsch", "de") @MangaSourceParser("NINEMANGA_DE", "NineManga Deutsch", "de")
class Deutsch(context: MangaLoaderContext) : NineMangaParser( class Deutsch(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaParserSource.NINEMANGA_DE, MangaSource.NINEMANGA_DE,
"de.ninemanga.com", "de.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_BR", "NineManga Brasil", "pt") @MangaSourceParser("NINEMANGA_BR", "NineManga Brasil", "pt")
class Brazil(context: MangaLoaderContext) : NineMangaParser( class Brazil(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaParserSource.NINEMANGA_BR, MangaSource.NINEMANGA_BR,
"br.ninemanga.com", "br.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_IT", "NineManga Italiano", "it") @MangaSourceParser("NINEMANGA_IT", "NineManga Italiano", "it")
class Italiano(context: MangaLoaderContext) : NineMangaParser( class Italiano(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaParserSource.NINEMANGA_IT, MangaSource.NINEMANGA_IT,
"it.ninemanga.com", "it.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_FR", "NineManga Français", "fr") @MangaSourceParser("NINEMANGA_FR", "NineManga Français", "fr")
class Francais(context: MangaLoaderContext) : NineMangaParser( class Francais(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaParserSource.NINEMANGA_FR, MangaSource.NINEMANGA_FR,
"fr.ninemanga.com", "fr.ninemanga.com",
) )
} }

@ -5,51 +5,37 @@ import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.parsers.Broken
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.asTypedList
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import org.koitharu.kotatsu.parsers.util.json.toJSONList
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@Broken @MangaSourceParser("NINENINENINEHENTAI", "999Hentai", type = ContentType.HENTAI)
@MangaSourceParser("NINENINENINEHENTAI", "AnimeH", type = ContentType.HENTAI)
internal class NineNineNineHentaiParser(context: MangaLoaderContext) : internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
PagedMangaParser(context, MangaParserSource.NINENINENINEHENTAI, PAGE_SIZE), Interceptor { PagedMangaParser(context, MangaSource.NINENINENINEHENTAI, size), Interceptor {
override val configKeyDomain = ConfigKey.Domain("animeh.to") override val configKeyDomain = ConfigKey.Domain("999hentai.net")
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val availableSortOrders: EnumSet<SortOrder> = EnumSet.of( override val availableSortOrders: EnumSet<SortOrder> = EnumSet.of(
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.NEWEST, SortOrder.NEWEST,
) )
override val filterCapabilities: MangaListFilterCapabilities override val isMultipleTagsSupported = false
get() = MangaListFilterCapabilities(
isSearchSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions( override suspend fun getAvailableLocales() = setOf(
availableTags = fetchAvailableTags(), Locale.ENGLISH,
availableLocales = setOf( Locale.CHINESE,
Locale.ENGLISH, Locale.JAPANESE,
Locale.CHINESE, Locale("es"),
Locale.JAPANESE,
Locale("es"),
),
) )
private fun Locale?.getSiteLang(): String { private fun Locale?.getSiteLang(): String {
@ -75,7 +61,7 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
return chain.proceed(newRequest) return chain.proceed(newRequest)
} }
private val cdnHost = suspendLazy(initializer = ::getUpdatedCdnHost) private val cdnHost = SuspendLazy(::getUpdatedCdnHost)
private suspend fun getUpdatedCdnHost(): String { private suspend fun getUpdatedCdnHost(): String {
val url = "https://$domain/manga-home" val url = "https://$domain/manga-home"
@ -84,7 +70,7 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
return cdn?.toHttpUrlOrNull()?.host ?: "edge.fast4speed.rsvp" return cdn?.toHttpUrlOrNull()?.host ?: "edge.fast4speed.rsvp"
} }
private suspend fun fetchAvailableTags(): Set<MangaTag> { override suspend fun getAvailableTags(): Set<MangaTag> {
val query = """ val query = """
queryTags( queryTags(
search: {format:"tagchapter",sortBy:Popular} search: {format:"tagchapter",sortBy:Popular}
@ -111,15 +97,23 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
} }
} }
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> { override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
return if (filter.query.isNullOrEmpty()) { return when (filter) {
if (filter.tags.isEmpty() && order == SortOrder.POPULARITY) { is MangaListFilter.Advanced -> {
getPopularList(page, filter.locale) if (filter.tags.isEmpty() && filter.sortOrder == SortOrder.POPULARITY) {
} else { getPopularList(page, filter.locale)
getSearchList(page, null, filter.locale, filter.tags, order) } else {
getSearchList(page, null, filter.locale, filter.tags, filter.sortOrder)
}
}
is MangaListFilter.Search -> {
getSearchList(page, filter.query, null, null, filter.sortOrder)
}
else -> {
getPopularList(page, null)
} }
} else {
getSearchList(page, filter.query, null, null, order)
} }
} }
@ -129,7 +123,7 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
): List<Manga> { ): List<Manga> {
val query = """ val query = """
queryPopularChapters( queryPopularChapters(
size: $PAGE_SIZE size: $size
language: "${locale.getSiteLang()}" language: "${locale.getSiteLang()}"
dateRange: 1 dateRange: 1
page: $page page: $page
@ -170,7 +164,7 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
} }
val query = """ val query = """
queryChapters( queryChapters(
limit: $PAGE_SIZE limit: $size
search: {$searchPayload} search: {$searchPayload}
page: $page page: $page
) { ) {
@ -200,14 +194,14 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
Manga( Manga(
id = generateUid(id), id = generateUid(id),
title = name.replace(shortenTitleRegex, "").trim(), title = name.replace(shortenTitleRegex, "").trim(),
altTitles = setOf(name), altTitle = name,
coverUrl = when { coverUrl = when {
cover?.startsWith("http") == true -> cover cover?.startsWith("http") == true -> cover
cover == null -> null cover == null -> ""
else -> "https://${cdnHost.get()}/$cover" else -> "https://${cdnHost.get()}/$cover"
}, },
authors = emptySet(), author = null,
contentRating = ContentRating.ADULT, isNsfw = true,
url = id, url = id,
publicUrl = "/hchapter/$id".toAbsoluteUrl(domain), publicUrl = "/hchapter/$id".toAbsoluteUrl(domain),
tags = emptySet(), tags = emptySet(),
@ -264,14 +258,13 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
type = it.getStringOrNull("tagType"), type = it.getStringOrNull("tagType"),
) )
} }
val author = tags?.filter { it.type == "artist" }?.joinToString { it.name.toCamelCase() }?.nullIfEmpty()
return manga.copy( return manga.copy(
title = name.replace(shortenTitleRegex, "").trim(), title = name.replace(shortenTitleRegex, "").trim(),
altTitles = setOf(name), altTitle = name,
coverUrl = cover.first, coverUrl = cover.first,
largeCoverUrl = cover.second, largeCoverUrl = cover.second,
authors = setOfNotNull(author), author = tags?.filter { it.type == "artist" }?.joinToString { it.name.toCamelCase() },
contentRating = ContentRating.ADULT, isNsfw = true,
tags = tags?.mapToSet { tags = tags?.mapToSet {
MangaTag( MangaTag(
title = it.name.toCamelCase(), title = it.name.toCamelCase(),
@ -284,9 +277,8 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
chapters = listOf( chapters = listOf(
MangaChapter( MangaChapter(
id = generateUid(id), id = generateUid(id),
title = name, name = name,
number = 1f, number = 1,
volume = 0,
url = id, url = id,
uploadDate = runCatching { uploadDate = runCatching {
dateFormat.parse(entry.getString("uploadDate"))!!.time dateFormat.parse(entry.getString("uploadDate"))!!.time
@ -326,7 +318,7 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
_id: "${seed.url}" _id: "${seed.url}"
search: {sortBy:Popular} search: {sortBy:Popular}
page: 1 page: 1
size: $PAGE_SIZE size: $size
) { ) {
chapters { chapters {
_id _id
@ -368,8 +360,8 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
} }
} }
val pics = pages.getJSONArray("pics").asTypedList<JSONObject>() val pics = pages.getJSONArray("pics").toJSONList()
val picsS = pages.getJSONArray("picsS").asTypedList<JSONObject>() val picsS = pages.getJSONArray("picsS").toJSONList()
return pics.zip(picsS).map { return pics.zip(picsS).map {
val img = it.first.getString("url") val img = it.first.getString("url")
@ -388,7 +380,7 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) :
} }
companion object { companion object {
private const val PAGE_SIZE = 20 private const val size = 20
private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""") private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""")
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH)
} }

@ -1,56 +1,85 @@
package org.koitharu.kotatsu.parsers.site.all package org.koitharu.kotatsu.parsers.site.all
import androidx.collection.arraySetOf
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import org.jsoup.nodes.Element import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.SoftSuspendLazy
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.generateUid
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed
import org.koitharu.kotatsu.parsers.util.mapChapters
import org.koitharu.kotatsu.parsers.util.oneOrThrowIfMany
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import org.koitharu.kotatsu.parsers.util.urlEncoded
import java.util.Calendar
import java.util.EnumSet import java.util.EnumSet
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
internal abstract class WebtoonsParser( internal abstract class WebtoonsParser(
context: MangaLoaderContext, context: MangaLoaderContext,
source: MangaParserSource, source: MangaSource,
) : AbstractMangaParser(context, source) { ) : MangaParser(context, source) {
override val isMultipleTagsSupported = false
private val signer by lazy {
WebtoonsUrlSigner("gUtPzJFZch4ZyAGviiyH94P99lQ3pFdRTwpJWDlSGFfwgpr6ses5ALOxWHOIT7R1")
}
// we don't __really__ support changing this domain because:
// 1. I don't think other websites have this exact API
// 2. most communication is done with other domains (hosting API and static content), which are not configurable
// 3. we rely on the HTTP client setting the referer header to webtoons.com
//
// This effectively means that changing the domain will break the source. Yikes
override val configKeyDomain = ConfigKey.Domain("webtoons.com") override val configKeyDomain = ConfigKey.Domain("webtoons.com")
private val mobileApiDomain = "m.webtoons.com" private val apiDomain = "global.apis.naver.com"
private val staticDomain = "webtoon-phinf.pstatic.net" private val staticDomain = "webtoon-phinf.pstatic.net"
override val availableSortOrders: EnumSet<SortOrder> = EnumSet.of( override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY, SortOrder.POPULARITY, // views
SortOrder.RATING, SortOrder.RATING, // star rating
//SortOrder.LIKE, // likes
SortOrder.UPDATED, SortOrder.UPDATED,
) )
override val headers: Headers
override val filterCapabilities: MangaListFilterCapabilities get() = Headers.Builder().add("User-Agent", "nApps (Android 12;; linewebtoon; 3.1.0)").build()
get() = MangaListFilterCapabilities(
isSearchSupported = true,
)
override val userAgentKey =
ConfigKey.UserAgent("Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36")
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = availableTags(),
)
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
return page.url.toAbsoluteUrl(staticDomain) return page.url.toAbsoluteUrl(staticDomain)
} }
// some language tags do not map perfectly to the ones used by the API
private val languageCode: String private val languageCode: String
get() = when (val tag = sourceLocale.toLanguageTag()) { get() = when (val tag = sourceLocale.toLanguageTag()) {
"in" -> "id" "in" -> "id"
@ -58,242 +87,272 @@ internal abstract class WebtoonsParser(
else -> tag else -> tag
} }
private suspend fun fetchEpisodes(titleNo: Long): List<MangaChapter> { private suspend fun fetchEpisodes(titleNo: Long): List<MangaChapter> = coroutineScope {
val url = "https://$mobileApiDomain/api/v1/webtoon/$titleNo/episodes?pageSize=99999" val firstResult =
val json = webClient.httpGet(url).parseJson() makeRequest("/lineWebtoon/webtoon/episodeList.json?v=5&titleNo=$titleNo&startIndex=0&pageSize=30")
val totalEpisodeCount = firstResult.getJSONObject("episodeList").getInt("totalServiceEpisodeCount")
val episodes = firstResult.getJSONObject("episodeList").getJSONArray("episode").toJSONList().toMutableList()
val episodeList = json.optJSONObject("result")?.optJSONArray("episodeList") val additionalEpisodes = (episodes.size until totalEpisodeCount step 30).map { startIndex ->
?: throw ParseException("No episodes found for title $titleNo", url) async {
makeRequest("/lineWebtoon/webtoon/episodeList.json?v=5&titleNo=$titleNo&startIndex=$startIndex&pageSize=30").getJSONObject(
"episodeList",
).getJSONArray("episode").toJSONList()
}
}.awaitAll().flatten()
return episodeList.mapChapters { _, jo -> episodes.addAll(additionalEpisodes)
val episodeTitle = jo.getStringOrNull("episodeTitle") ?: ""
val episodeNo = jo.getInt("episodeNo")
val viewerLink = jo.getString("viewerLink")
// Optimize object creation and sorting
episodes.mapChapters { i, jo ->
MangaChapter( MangaChapter(
id = generateUid("$titleNo-$episodeNo"), id = generateUid("$titleNo-$i"),
title = episodeTitle, name = jo.getString("episodeTitle"),
number = episodeNo.toFloat(), number = jo.getInt("episodeSeq"),
volume = 0, url = "$titleNo-${jo.get("episodeNo")}",
url = viewerLink, uploadDate = jo.getLong("registerYmdt"),
uploadDate = jo.getLong("exposureDateMillis"),
branch = null, branch = null,
scanlator = null, scanlator = null,
source = source, source = source,
) )
}.sortedBy(MangaChapter::number) }.sortedBy(MangaChapter::number)
}
private fun JSONArray.toJSONList(): List<JSONObject> {
val list = mutableListOf<JSONObject>()
for (i in 0 until length()) {
list.add(getJSONObject(i))
}
return list
} }
override suspend fun getDetails(manga: Manga): Manga = coroutineScope { override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val titleNo = manga.url.toLong() val titleNo = manga.url.toLong()
val detailsUrl = manga.publicUrl.ifBlank { val chaptersDeferred = async { fetchEpisodes(titleNo) }
"https://$domain/$languageCode/drama/placeholder/list?title_no=$titleNo" val chapters = chaptersDeferred.await()
} makeRequest("/lineWebtoon/webtoon/titleInfo.json?titleNo=${titleNo}&anyServiceStatus=false").getJSONObject("titleInfo")
.let { jo ->
val doc = webClient.httpGet(detailsUrl).parseHtml() MangaWebtoon(
Manga(
val title = doc.select("meta[property='og:title']").attr("content") id = generateUid(titleNo),
.ifEmpty { doc.select("h1.subj, h3.subj").text().ifEmpty { manga.title } } title = jo.getString("title"),
altTitle = null,
val description = listOf( url = "$titleNo",
doc.select("meta[property='og:description']").attr("content"), publicUrl = "https://$domain/$languageCode/originals/a/list?title_no=${titleNo}",
doc.select("#_asideDetail p.summary").text(), rating = jo.getFloatOrDefault("starScoreAverage", -10f) / 10f,
doc.select(".detail_header .summary").text(), isNsfw = jo.getBooleanOrDefault("ageGradeNotice", isNsfwSource),
).firstOrNull { it.isNotBlank() }.orEmpty() coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain),
largeCoverUrl = jo.getStringOrNull("thumbnailVertical")?.toAbsoluteUrl(staticDomain),
val coverUrl = doc.select("meta[property=\"og:image\"]").attr("content").let { url -> tags = setOf(parseTag(jo.getJSONObject("genreInfo"))),
if (url.isNotBlank()) url.toAbsoluteUrl(staticDomain) else manga.coverUrl author = jo.getStringOrNull("writingAuthorName"),
} description = jo.getString("synopsis"),
// I don't think the API provides this info,
val author = listOf( state = null,
doc.select("meta[property='com-linewebtoon:webtoon:author']").attr("content"), chapters = chapters,
doc.select(".detail_header .info .author").firstOrNull()?.text(), source = source,
doc.select(".author_area").text(), ),
).firstOrNull { !it.isNullOrBlank() && it != "null" } date = jo.getLong("lastEpisodeRegisterYmdt"),
readCount = jo.getLong("readCount"),
val genreElements = doc.select(".detail_header .info .genre").ifEmpty { //likeCount = jo.getLong("likeitCount"),
doc.select("h2.genre") ).manga
} }
val genres = genreElements.map { it.text() }.toSet() }
val dayInfo = doc.select("#_asideDetail p.day_info").text().ifEmpty { private val allGenreCache = SuspendLazy {
doc.select(".day_info").text() makeRequest("/lineWebtoon/webtoon/genreList.json").getJSONObject("genreList").getJSONArray("genres")
} .mapJSON { jo -> parseTag(jo) }.associateBy { tag -> tag.key }
val state = when { }
dayInfo.contains("UP") || dayInfo.contains("EVERY") || dayInfo.contains("NOUVEAU") -> MangaState.ONGOING
dayInfo.contains("END") || dayInfo.contains("COMPLETED") || dayInfo.contains("TERMINÉ") -> MangaState.FINISHED
else -> null
}
val chapters = async { fetchEpisodes(titleNo) }.await() private val allTitleCache = SoftSuspendLazy {
makeRequest("/lineWebtoon/webtoon/titleList.json?").getJSONObject("titleList").getJSONArray("titles")
Manga( .mapJSON { jo ->
id = generateUid(titleNo), val titleNo = jo.getLong("titleNo")
title = title, MangaWebtoon(
altTitles = emptySet(), Manga(
url = "$titleNo", id = generateUid(titleNo),
publicUrl = detailsUrl, url = titleNo.toString(),
rating = RATING_UNKNOWN, publicUrl = "https://$domain/$languageCode/originals/a/list?title_no=$titleNo",
contentRating = null, title = jo.getString("title"),
coverUrl = coverUrl, coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain),
largeCoverUrl = null, altTitle = null,
tags = genres.map { genre -> MangaTag(title = genre, key = genre.lowercase(), source = source) }.toSet(), author = jo.getStringOrNull("writingAuthorName"),
authors = setOfNotNull(author.takeIf { it != "null" }), isNsfw = jo.getBooleanOrDefault("ageGradeNotice", isNsfwSource),
description = description, rating = jo.getFloatOrDefault("starScoreAverage", -10f) / 10f,
state = state, tags = setOfNotNull(allGenreCache.get()[jo.getString("representGenre")]),
chapters = chapters, description = jo.getString("synopsis"),
source = source, state = null,
) source = source,
),
date = jo.getLong("lastEpisodeRegisterYmdt"),
readCount = jo.getLong("readCount"),
//likeCount = jo.getLong("likeitCount"),
)
}
} }
private fun getSortOrderParam(order: SortOrder): String { private suspend fun getAllGenreList(): Map<String, MangaTag> {
return when (order) { return allGenreCache.get()
SortOrder.POPULARITY -> "MANA"
SortOrder.RATING -> "LIKEIT"
SortOrder.UPDATED -> "UPDATE"
else -> "MANA"
}
} }
private fun availableTags() = arraySetOf( private suspend fun getAllTitleList(): List<MangaWebtoon> {
MangaTag("Action", "action", source), return allTitleCache.get()
MangaTag("Comedy", "comedy", source),
MangaTag("Drama", "drama", source),
MangaTag("Fantasy", "fantasy", source),
MangaTag("Horror", "horror", source),
MangaTag("Romance", "romance", source),
MangaTag("Sci-Fi", "sf", source),
MangaTag("Slice of Life", "slice_of_life", source),
MangaTag("Sports", "sports", source),
MangaTag("Supernatural", "supernatural", source),
MangaTag("Thriller", "thriller", source),
MangaTag("Historical", "historical", source),
MangaTag("Mystery", "mystery", source),
MangaTag("Superhero", "super_hero", source),
MangaTag("Heartwarming", "heartwarming", source),
MangaTag("Graphic Novel", "graphic_novel", source),
MangaTag("Informative", "tiptoon", source),
)
private val genreUrlMap: Map<String, String> = availableTags().associate {
it.title.lowercase() to it.key
} }
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> { override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
val document = when {
!filter.query.isNullOrEmpty() -> { val webtoons = when (filter) {
val searchUrl = "https://$domain/$languageCode/search?keyword=${filter.query.urlEncoded()}" is MangaListFilter.Search -> {
webClient.httpGet(searchUrl).parseHtml() makeRequest("/lineWebtoon/webtoon/searchWebtoon?query=${filter.query.urlEncoded()}").getJSONObject("webtoonSearch")
.getJSONArray("titleList").mapJSON { jo ->
val titleNo = jo.getLong("titleNo")
MangaWebtoon(
Manga(
id = generateUid(titleNo),
title = jo.getString("title"),
altTitle = null,
url = titleNo.toString(),
publicUrl = "https://$domain/$languageCode/originals/a/list?title_no=$titleNo",
rating = RATING_UNKNOWN,
isNsfw = isNsfwSource,
coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain),
largeCoverUrl = null,
tags = emptySet(),
author = jo.getStringOrNull("writingAuthorName"),
description = null,
state = null,
source = source,
))
}
} }
filter.tags.isNotEmpty() -> { is MangaListFilter.Advanced -> {
val selectedGenre = filter.tags.first() val genre = filter.tags.oneOrThrowIfMany()?.key ?: "ALL"
val genreUrlPath = genreUrlMap[selectedGenre.key] ?: selectedGenre.key
val sortParam = getSortOrderParam(order)
val genreUrl = "https://$domain/$languageCode/genres/$genreUrlPath?sortOrder=$sortParam"
webClient.httpGet(genreUrl).parseHtml()
}
else -> { val genres = getAllGenreList()
val rankingType = when (order) { val result = getAllTitleList()
SortOrder.POPULARITY -> "popular"
SortOrder.RATING -> "trending" val sortedResult = when (filter.sortOrder) {
SortOrder.UPDATED -> "originals" SortOrder.UPDATED -> result.sortedBy { it.date }
else -> "popular" SortOrder.POPULARITY -> result.sortedByDescending { it.readCount }
SortOrder.RATING -> result.sortedByDescending { it.manga.rating }
//SortOrder.LIKE -> result.sortedBy { it.likeitCount }
else -> throw IllegalArgumentException("Unsupported sort order: ${filter.sortOrder}")
}
if (genre != "ALL") {
sortedResult.filter { it.manga.tags.contains(genres[genre]) }
} else {
sortedResult
} }
val rankingUrl = "https://$domain/$languageCode/ranking/$rankingType"
webClient.httpGet(rankingUrl).parseHtml()
} }
}
val selectedGenreForManga = if (filter.tags.isNotEmpty()) filter.tags.first() else null else -> getAllTitleList()
return document.select(".webtoon_list li a, .card_wrap .card_item a") }
.map { element -> createMangaFromElement(element, source, selectedGenreForManga) } return webtoons.map { it.manga }.subList(offset, (offset + 20).coerceAtMost(webtoons.size))
.drop(offset)
.take(20)
} }
private fun createMangaFromElement( override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
element: Element, val (titleNo, episodeNo) = requireNotNull(chapter.url.splitTwoParts('-'))
source: MangaParserSource, return makeRequest("/lineWebtoon/webtoon/episodeInfo.json?v=4&titleNo=$titleNo&episodeNo=$episodeNo").getJSONObject(
selectedGenre: MangaTag? = null, "episodeInfo",
): Manga { ).getJSONArray("imageInfo").mapJSONIndexed { i, jo ->
val href = element.absUrl("href") MangaPage(
val titleNo = extractTitleNoFromUrl(href) id = generateUid("$titleNo-$episodeNo-$i"),
val title = element.select(".title, .card_title").text() url = jo.getString("url"),
val thumbnailUrl = element.select("img").attr("src") preview = null,
source = source,
return Manga( )
id = generateUid(titleNo), }
title = title, }
altTitles = emptySet(),
url = titleNo.toString(), private fun parseTag(jo: JSONObject): MangaTag {
publicUrl = href, return MangaTag(
rating = RATING_UNKNOWN, title = jo.getString("name"),
contentRating = null, key = jo.getString("code"),
coverUrl = thumbnailUrl.toAbsoluteUrl(staticDomain),
largeCoverUrl = null,
tags = selectedGenre?.let { setOf(it) } ?: emptySet(),
authors = emptySet(),
description = null,
state = null,
source = source, source = source,
) )
} }
private fun extractTitleNoFromUrl(url: String): Long { override suspend fun getAvailableTags(): Set<MangaTag> {
return Regex("title_no=(\\d+)").find(url)?.groupValues?.get(1)?.toLong() return getAllGenreList().values.toSet()
?: throw ParseException("Could not extract title_no from URL: $url", url)
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { private suspend fun makeRequest(url: String): JSONObject {
val doc = try { val resp = webClient.httpGet(finalizeUrl(url))
val absUrl = chapter.url.toAbsoluteUrl(domain) val message: JSONObject? = resp.parseJson().optJSONObject("message")
webClient.httpGet(absUrl).parseHtml() return when (resp.code) {
} catch (e: Exception) { in 200..299 -> checkNotNull(message).getJSONObject("result")
throw ParseException("Failed to get pages for chapter: ${chapter.title}", chapter.url, e) 404 -> throw NotFoundException(message?.getStringOrNull("message").orEmpty(), url)
} else -> {
val code = message?.getIntOrDefault("code", 0)
fun extractImages(selector: String, attr: String = "data-url"): List<MangaPage> { val errorMessage = message?.getStringOrNull("message")
return doc.select(selector).mapIndexedNotNull { i, element -> throw ParseException("Api error (code=$code): $errorMessage", url)
val url = element.attr(attr).takeIf { it.isNotBlank() }
?: element.attr("src").takeIf { it.contains(staticDomain) }
?: return@mapIndexedNotNull null
MangaPage(
id = generateUid("${chapter.id}-$i"),
url = url,
preview = null,
source = source,
)
} }
} }
}
return extractImages("div#_imageList > img") private fun finalizeUrl(url: String): HttpUrl {
.ifEmpty { extractImages("canvas[data-url]") } val httpUrl = url.toAbsoluteUrl(apiDomain).toHttpUrl()
.ifEmpty { extractImages("img[src*='$staticDomain'], img[data-url*='$staticDomain']") } val builder = httpUrl.newBuilder().addQueryParameter("serviceZone", "GLOBAL")
.ifEmpty { throw ParseException("No images found in chapter.", chapter.url) } if (httpUrl.queryParameter("v") == null) {
builder.addQueryParameter("v", "1")
}
builder.addQueryParameter("language", languageCode).addQueryParameter("locale", "languageCode")
.addQueryParameter("platform", "APP_ANDROID")
signer.makeEncryptUrl(builder)
return builder.build()
} }
@MangaSourceParser("WEBTOONS_EN", "Webtoons English", "en", type = ContentType.MANGA) @MangaSourceParser("WEBTOONS_EN", "Webtoons English", "en", type = ContentType.MANGA)
class English(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_EN) class English(context: MangaLoaderContext) : WebtoonsParser(context, MangaSource.WEBTOONS_EN)
@MangaSourceParser("WEBTOONS_ID", "Webtoons Indonesia", "id", type = ContentType.MANGA) @MangaSourceParser("WEBTOONS_ID", "Webtoons Indonesia", "id", type = ContentType.MANGA)
class Indonesian(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ID) class Indonesian(context: MangaLoaderContext) : WebtoonsParser(context, MangaSource.WEBTOONS_ID)
@MangaSourceParser("WEBTOONS_ES", "Webtoons Spanish", "es", type = ContentType.MANGA) @MangaSourceParser("WEBTOONS_ES", "Webtoons Spanish", "es", type = ContentType.MANGA)
class Spanish(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ES) class Spanish(context: MangaLoaderContext) : WebtoonsParser(context, MangaSource.WEBTOONS_ES)
@MangaSourceParser("WEBTOONS_FR", "Webtoons French", "fr", type = ContentType.MANGA) @MangaSourceParser("WEBTOONS_FR", "Webtoons French", "fr", type = ContentType.MANGA)
class French(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_FR) class French(context: MangaLoaderContext) : WebtoonsParser(context, MangaSource.WEBTOONS_FR)
@MangaSourceParser("WEBTOONS_TH", "Webtoons Thai", "th", type = ContentType.MANGA) @MangaSourceParser("WEBTOONS_TH", "Webtoons Thai", "th", type = ContentType.MANGA)
class Thai(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_TH) class Thai(context: MangaLoaderContext) : WebtoonsParser(context, MangaSource.WEBTOONS_TH)
@MangaSourceParser("WEBTOONS_ZH", "Webtoons Chinese", "zh", type = ContentType.MANGA) @MangaSourceParser("WEBTOONS_ZH", "Webtoons Chinese", "zh", type = ContentType.MANGA)
class Chinese(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ZH) class Chinese(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaSource.WEBTOONS_ZH)
@MangaSourceParser("WEBTOONS_DE", "Webtoons German", "de", type = ContentType.MANGA) @MangaSourceParser("WEBTOONS_DE", "Webtoons German", "de", type = ContentType.MANGA)
class German(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_DE) class German(context: MangaLoaderContext) : LineWebtoonsParser(context, MangaSource.WEBTOONS_DE)
private inner class WebtoonsUrlSigner(private val secret: String) {
private val mac = Mac.getInstance("HmacSHA1").apply {
this.init(SecretKeySpec(secret.encodeToByteArray(), "HmacSHA1"))
}
private fun getMessage(url: String, msgpad: String): String {
return url.substring(0, 0xFF.coerceAtMost(url.length)) + msgpad
}
private fun getMessageDigest(s: String): String {
val signedMessage = synchronized(mac) { mac.doFinal(s.toByteArray()) }
return context.encodeBase64(signedMessage)
}
fun makeEncryptUrl(urlBuilder: HttpUrl.Builder) {
val msgPad = Calendar.getInstance().timeInMillis.toString()
val digest = getMessageDigest(getMessage(urlBuilder.build().toString(), msgPad))
urlBuilder.addQueryParameter("msgpad", msgPad).addQueryParameter("md", digest)
// .addEncodedQueryParameter("md", digest.urlEncoded())
}
}
private inner class MangaWebtoon(
val manga: Manga,
@JvmField val date: Long? = null,
@JvmField val readCount: Long? = null,
)
} }

@ -5,25 +5,22 @@ import kotlinx.coroutines.coroutineScope
import org.json.JSONArray import org.json.JSONArray
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.util.* import java.util.*
internal abstract class AnimeBootstrapParser( internal abstract class AnimeBootstrapParser(
context: MangaLoaderContext, context: MangaLoaderContext,
source: MangaParserSource, source: MangaSource,
domain: String, domain: String,
pageSize: Int = 24, pageSize: Int = 24,
) : PagedMangaParser(context, source, pageSize) { ) : PagedMangaParser(context, source, pageSize) {
override val configKeyDomain = ConfigKey.Domain(domain) override val configKeyDomain = ConfigKey.Domain(domain)
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) { override val isMultipleTagsSupported = false
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
override val availableSortOrders: Set<SortOrder> = EnumSet.of( override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED, SortOrder.UPDATED,
@ -35,27 +32,13 @@ internal abstract class AnimeBootstrapParser(
protected open val listUrl = "/manga" protected open val listUrl = "/manga"
protected open val datePattern = "dd MMM. yyyy" protected open val datePattern = "dd MMM. yyyy"
init { init {
paginator.firstPage = 1 paginator.firstPage = 1
searchPaginator.firstPage = 1 searchPaginator.firstPage = 1
} }
override val filterCapabilities: MangaListFilterCapabilities override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
get() = MangaListFilterCapabilities(
isSearchSupported = true,
isSearchWithFiltersSupported = true,
)
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = fetchAvailableTags(),
availableContentTypes = EnumSet.of(
ContentType.MANGA,
ContentType.MANHWA,
ContentType.MANHUA,
),
)
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
@ -64,37 +47,32 @@ internal abstract class AnimeBootstrapParser(
append(page.toString()) append(page.toString())
append("&type=all") append("&type=all")
filter.query?.let { when (filter) {
append("&search=") is MangaListFilter.Search -> {
append(filter.query.urlEncoded()) append("&search=")
} append(filter.query.urlEncoded())
}
filter.tags.oneOrThrowIfMany()?.let { is MangaListFilter.Advanced -> {
append("&categorie=")
append(it.key)
}
filter.types.oneOrThrowIfMany()?.let { filter.tags.oneOrThrowIfMany()?.let {
append("&type=") append("&categorie=")
append( append(it.key)
when (it) { }
ContentType.MANGA -> "manga"
ContentType.MANHWA -> "manhwa"
ContentType.MANHUA -> "manhua"
else -> "all"
},
)
}
append("&sort=") append("&sort=")
when (order) { when (filter.sortOrder) {
SortOrder.POPULARITY -> append("view") SortOrder.POPULARITY -> append("view")
SortOrder.UPDATED -> append("updated") SortOrder.UPDATED -> append("updated")
SortOrder.ALPHABETICAL -> append("default") SortOrder.ALPHABETICAL -> append("default")
SortOrder.NEWEST -> append("published") SortOrder.NEWEST -> append("published")
else -> append("updated") else -> append("updated")
} }
}
null -> append("&sort=updated")
}
} }
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
@ -104,21 +82,20 @@ internal abstract class AnimeBootstrapParser(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.toAbsoluteUrl(div.host ?: domain), publicUrl = href.toAbsoluteUrl(div.host ?: domain),
coverUrl = div.selectFirstOrThrow("div.product__item__pic") coverUrl = div.selectFirstOrThrow("div.product__item__pic").attr("data-setbg").orEmpty(),
.attrAsAbsoluteUrlOrNull("data-setbg"),
title = div.selectFirstOrThrow("div.product__item__text").text().orEmpty(), title = div.selectFirstOrThrow("div.product__item__text").text().orEmpty(),
altTitles = emptySet(), altTitle = null,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
tags = emptySet(), tags = emptySet(),
authors = emptySet(), author = null,
state = null, state = null,
source = source, source = source,
contentRating = if (isNsfwSource) ContentRating.ADULT else null, isNsfw = isNsfwSource,
) )
} }
} }
protected open suspend fun fetchAvailableTags(): Set<MangaTag> { override suspend fun getAvailableTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://$domain$listUrl").parseHtml() val doc = webClient.httpGet("https://$domain$listUrl").parseHtml()
return doc.select("div.product__page__filter div:contains(Genre:) option ").mapNotNullToSet { option -> return doc.select("div.product__page__filter div:contains(Genre:) option ").mapNotNullToSet { option ->
val key = option.attr("value") ?: return@mapNotNullToSet null val key = option.attr("value") ?: return@mapNotNullToSet null
@ -147,7 +124,7 @@ internal abstract class AnimeBootstrapParser(
} }
manga.copy( manga.copy(
tags = doc.body().select(selectTag).mapToSet { a -> tags = doc.body().select(selectTag).mapNotNullToSet { a ->
MangaTag( MangaTag(
key = a.attr("href").substringAfterLast('='), key = a.attr("href").substringAfterLast('='),
title = a.text().toTitleCase().replace(",", ""), title = a.text().toTitleCase().replace(",", ""),
@ -168,9 +145,8 @@ internal abstract class AnimeBootstrapParser(
val href = a.attr("href") val href = a.attr("href")
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
title = a.text(), name = a.text(),
number = i + 1f, number = i + 1,
volume = 0,
url = href, url = href,
uploadDate = 0, uploadDate = 0,
source = source, source = source,

@ -3,20 +3,20 @@ package org.koitharu.kotatsu.parsers.site.animebootstrap.fr
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.Broken
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.site.animebootstrap.AnimeBootstrapParser import org.koitharu.kotatsu.parsers.site.animebootstrap.AnimeBootstrapParser
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.EnumSet
import java.util.Locale
@Broken
@MangaSourceParser("PAPSCAN", "PapScan", "fr") @MangaSourceParser("PAPSCAN", "PapScan", "fr")
internal class PapScan(context: MangaLoaderContext) : internal class PapScan(context: MangaLoaderContext) :
AnimeBootstrapParser(context, MangaParserSource.PAPSCAN, "papscan.com") { AnimeBootstrapParser(context, MangaSource.PAPSCAN, "papscan.com") {
override val sourceLocale: Locale = Locale.ENGLISH override val sourceLocale: Locale = Locale.ENGLISH
override val isMultipleTagsSupported = false
override val listUrl = "/liste-manga" override val listUrl = "/liste-manga"
override val selectState = "div.anime__details__widget li:contains(En cours)" override val selectState = "div.anime__details__widget li:contains(En cours)"
override val selectTag = "div.anime__details__widget li:contains(Genre) a" override val selectTag = "div.anime__details__widget li:contains(Genre) a"
@ -28,20 +28,20 @@ internal class PapScan(context: MangaLoaderContext) :
SortOrder.ALPHABETICAL_DESC, SortOrder.ALPHABETICAL_DESC,
) )
override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga> { override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/filterList") append("/filterList")
append("?page=") append("?page=")
append(page.toString()) append(page.toString())
when { when (filter) {
!filter.query.isNullOrEmpty() -> { is MangaListFilter.Search -> {
append("&alpha=") append("&alpha=")
append(filter.query.urlEncoded()) append(filter.query.urlEncoded())
} }
else -> { is MangaListFilter.Advanced -> {
filter.tags.oneOrThrowIfMany()?.let { filter.tags.oneOrThrowIfMany()?.let {
append("&cat=") append("&cat=")
@ -49,7 +49,7 @@ internal class PapScan(context: MangaLoaderContext) :
} }
append("&sortBy=") append("&sortBy=")
when (order) { when (filter.sortOrder) {
SortOrder.POPULARITY -> append("views") SortOrder.POPULARITY -> append("views")
SortOrder.ALPHABETICAL_DESC -> append("name&asc=false") SortOrder.ALPHABETICAL_DESC -> append("name&asc=false")
SortOrder.ALPHABETICAL -> append("name&asc=true") SortOrder.ALPHABETICAL -> append("name&asc=true")
@ -57,6 +57,8 @@ internal class PapScan(context: MangaLoaderContext) :
} }
} }
null -> append("&sortBy=updated")
} }
} }
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
@ -66,23 +68,22 @@ internal class PapScan(context: MangaLoaderContext) :
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.toAbsoluteUrl(div.host ?: domain), publicUrl = href.toAbsoluteUrl(div.host ?: domain),
coverUrl = div.selectFirstOrThrow("div.product__item__pic") coverUrl = div.selectFirstOrThrow("div.product__item__pic").attr("data-setbg").orEmpty(),
.attrAsAbsoluteUrlOrNull("data-setbg"),
title = div.selectFirstOrThrow("div.product__item__text h5").text().orEmpty(), title = div.selectFirstOrThrow("div.product__item__text h5").text().orEmpty(),
altTitles = emptySet(), altTitle = null,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
tags = emptySet(), tags = emptySet(),
authors = emptySet(), author = null,
state = null, state = null,
source = source, source = source,
contentRating = if (isNsfwSource) ContentRating.ADULT else null, isNsfw = isNsfwSource,
) )
} }
} }
override suspend fun fetchAvailableTags(): Set<MangaTag> { override suspend fun getAvailableTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://$domain$listUrl").parseHtml() val doc = webClient.httpGet("https://$domain$listUrl").parseHtml()
return doc.select("a.category ").mapToSet { a -> return doc.select("a.category ").mapNotNullToSet { a ->
val key = a.attr("href").substringAfterLast('=') val key = a.attr("href").substringAfterLast('=')
val name = a.text() val name = a.text()
MangaTag( MangaTag(
@ -104,7 +105,7 @@ internal class PapScan(context: MangaLoaderContext) :
MangaState.ONGOING MangaState.ONGOING
} }
manga.copy( manga.copy(
tags = doc.body().select(selectTag).mapToSet { a -> tags = doc.body().select(selectTag).mapNotNullToSet { a ->
MangaTag( MangaTag(
key = a.attr("href").removeSuffix('/').substringAfterLast('/'), key = a.attr("href").removeSuffix('/').substringAfterLast('/'),
title = a.text().toTitleCase(), title = a.text().toTitleCase(),
@ -124,11 +125,10 @@ internal class PapScan(context: MangaLoaderContext) :
val dateText = li.selectFirst("span.date-chapter-title-rtl")?.text() val dateText = li.selectFirst("span.date-chapter-title-rtl")?.text()
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
title = li.selectFirstOrThrow("span em").text(), name = li.selectFirstOrThrow("span em").text(),
number = i + 1f, number = i + 1,
volume = 0,
url = href, url = href,
uploadDate = dateFormat.parseSafe(dateText), uploadDate = dateFormat.tryParse(dateText),
source = source, source = source,
scanlator = null, scanlator = null,
branch = null, branch = null,

@ -2,9 +2,9 @@ package org.koitharu.kotatsu.parsers.site.animebootstrap.id
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.site.animebootstrap.AnimeBootstrapParser import org.koitharu.kotatsu.parsers.site.animebootstrap.AnimeBootstrapParser
@MangaSourceParser("KOMIKZOID", "KomikzoId", "id") @MangaSourceParser("KOMIKZOID", "KomikzoId", "id")
internal class KomikzoId(context: MangaLoaderContext) : internal class KomikzoId(context: MangaLoaderContext) :
AnimeBootstrapParser(context, MangaParserSource.KOMIKZOID, "komikzoid.id") AnimeBootstrapParser(context, MangaSource.KOMIKZOID, "komikzoid.id")

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

Loading…
Cancel
Save