Compare commits
No commits in common. 'fe5534b006322188080f6a8fa1d3f04bddb3b6c1' and '071f4f091107fd9f66b635fc08c2a74e3337a397' have entirely different histories.
fe5534b006
...
071f4f0911
@ -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
|
|
||||||
@ -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>
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,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)
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
|
||||||
|
|
||||||
public enum class Demographic {
|
|
||||||
SHOUNEN,
|
|
||||||
SHOUJO,
|
|
||||||
SEINEN,
|
|
||||||
JOSEI,
|
|
||||||
KODOMO,
|
|
||||||
NONE,
|
|
||||||
}
|
|
||||||
@ -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(),
|
|
||||||
)
|
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model.search
|
|
||||||
|
|
||||||
import androidx.collection.ArrayMap
|
|
||||||
import androidx.collection.ArraySet
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a search query for filtering and sorting manga search results.
|
|
||||||
* This class is immutable and must be constructed using the [Builder].
|
|
||||||
*
|
|
||||||
* @property criteria The set of search criteria applied to the query.
|
|
||||||
* @property order The sorting order for the results (optional).
|
|
||||||
* @property offset The offset number for paginated search results (optional).
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Deprecated("Too complex. Use MangaListFilter instead")
|
|
||||||
@ConsistentCopyVisibility
|
|
||||||
public data class MangaSearchQuery private constructor(
|
|
||||||
@JvmField public val criteria: Set<QueryCriteria<*>>,
|
|
||||||
@JvmField public val order: SortOrder?,
|
|
||||||
@JvmField public val offset: Int,
|
|
||||||
@JvmField public val skipValidation: Boolean,
|
|
||||||
) {
|
|
||||||
|
|
||||||
public fun newBuilder(): Builder = Builder(this)
|
|
||||||
|
|
||||||
public class Builder {
|
|
||||||
|
|
||||||
private val criteria = ArraySet<QueryCriteria<*>>()
|
|
||||||
private var order: SortOrder? = null
|
|
||||||
private var offset: Int = 0
|
|
||||||
private var skipValidation: Boolean = false
|
|
||||||
|
|
||||||
public constructor()
|
|
||||||
|
|
||||||
public constructor(query: MangaSearchQuery) : this() {
|
|
||||||
criteria.addAll(query.criteria)
|
|
||||||
order = query.order
|
|
||||||
offset = query.offset
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun criterion(criterion: QueryCriteria<*>): Builder = apply { criteria.add(criterion) }
|
|
||||||
|
|
||||||
public fun order(order: SortOrder?): Builder = apply { this.order = order }
|
|
||||||
|
|
||||||
public fun offset(offset: Int): Builder = apply { this.offset = offset }
|
|
||||||
|
|
||||||
public fun skipValidation(skip: Boolean): Builder = apply { this.skipValidation = skip }
|
|
||||||
|
|
||||||
@Throws(IllegalArgumentException::class)
|
|
||||||
public fun build(): MangaSearchQuery {
|
|
||||||
return MangaSearchQuery(deduplicateCriteria(criteria), order, offset, skipValidation)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deduplicateCriteria(criteria: Set<QueryCriteria<*>>): Set<QueryCriteria<*>> {
|
|
||||||
val uniqueCriteria =
|
|
||||||
ArrayMap<Pair<SearchableField, Class<out QueryCriteria<*>>>, QueryCriteria<*>>(criteria.size)
|
|
||||||
|
|
||||||
for (criterion in criteria) {
|
|
||||||
val key = criterion.field to criterion::class.java
|
|
||||||
val existing = uniqueCriteria[key]
|
|
||||||
|
|
||||||
when {
|
|
||||||
existing == null -> uniqueCriteria[key] = criterion
|
|
||||||
|
|
||||||
existing is QueryCriteria.Include<*> && criterion is QueryCriteria.Include<*> -> {
|
|
||||||
uniqueCriteria[key] =
|
|
||||||
QueryCriteria.Include(criterion.field, existing.values union criterion.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
existing is QueryCriteria.Exclude<*> && criterion is QueryCriteria.Exclude<*> -> {
|
|
||||||
uniqueCriteria[key] =
|
|
||||||
QueryCriteria.Exclude(criterion.field, existing.values union criterion.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> throw IllegalArgumentException(
|
|
||||||
"Match and Range have only one criterion per type, but found duplicates for: ${criterion.field} in ${criterion::class.simpleName}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return uniqueCriteria.values.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
|
|
||||||
public val EMPTY: MangaSearchQuery = MangaSearchQuery(emptySet(), null, 0, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model.search
|
|
||||||
|
|
||||||
import androidx.collection.ArraySet
|
|
||||||
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
|
||||||
|
|
||||||
@Deprecated("Too complex. Use MangaListFilterCapabilities instead")
|
|
||||||
@ExposedCopyVisibility
|
|
||||||
public data class MangaSearchQueryCapabilities internal constructor(
|
|
||||||
public val capabilities: Set<SearchCapability>,
|
|
||||||
) {
|
|
||||||
|
|
||||||
public constructor(vararg capabilities: SearchCapability) : this(ArraySet(capabilities))
|
|
||||||
|
|
||||||
internal fun validate(query: MangaSearchQuery) {
|
|
||||||
val strictFields = capabilities.filter { it.isExclusive }.mapToSet { it.field }
|
|
||||||
val usedStrictFields = query.criteria.mapToSet { it.field }.intersect(strictFields)
|
|
||||||
|
|
||||||
require(usedStrictFields.isEmpty() || query.criteria.size <= 1) {
|
|
||||||
"Query contains multiple criteria, but at least one field (${usedStrictFields.joinToString()}) does not support multiple criteria."
|
|
||||||
}
|
|
||||||
for (criterion in query.criteria) {
|
|
||||||
val capability = requireNotNull(capabilities.find { it.field == criterion.field }) {
|
|
||||||
"Unsupported search field: ${criterion.field}"
|
|
||||||
}
|
|
||||||
|
|
||||||
require(criterion::class in capability.criteriaTypes) {
|
|
||||||
"Unsupported search criterion: ${criterion::class.simpleName} for field ${criterion.field}"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure single value per criterion if supportMultiValue is false
|
|
||||||
if (!capability.isMultiple) {
|
|
||||||
when (criterion) {
|
|
||||||
is Include<*> -> require(criterion.values.size <= 1) {
|
|
||||||
"Multiple values are not allowed for field ${criterion.field}"
|
|
||||||
}
|
|
||||||
|
|
||||||
is Exclude<*> -> require(criterion.values.size <= 1) {
|
|
||||||
"Multiple values are not allowed for field ${criterion.field}"
|
|
||||||
}
|
|
||||||
|
|
||||||
is Range<*> -> Unit // Range is always valid (from, to)
|
|
||||||
is Match<*> -> Unit // Match always has a single value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model.search
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a generic search criterion used for filtering manga search results.
|
|
||||||
* Each criterion applies a specific condition to a [SearchableField] and operates on values of type [T].
|
|
||||||
*
|
|
||||||
* @param T The type of value associated with the search criterion.
|
|
||||||
* @property field The field to which this search criterion applies.
|
|
||||||
*/
|
|
||||||
@Deprecated("Too complex")
|
|
||||||
public sealed interface QueryCriteria<T> {
|
|
||||||
|
|
||||||
public val field: SearchableField
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean
|
|
||||||
|
|
||||||
override fun hashCode(): Int
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents an inclusion criterion that allows search results based on a set of allowed values.
|
|
||||||
*
|
|
||||||
* @param T The type of value being included in the search.
|
|
||||||
* @property values The set of values that should be included in the search results.
|
|
||||||
*
|
|
||||||
* ### Example Usage:
|
|
||||||
* ```kotlin
|
|
||||||
* val genreFilter = QueryCriteria.Include(SearchableField.STATE, setOf(MangaState.ONGOING, MangaState.FINISHED))
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
public data class Include<T : Any>(
|
|
||||||
public override val field: SearchableField,
|
|
||||||
@JvmField public val values: Set<T>,
|
|
||||||
) : QueryCriteria<T> {
|
|
||||||
|
|
||||||
init {
|
|
||||||
check(values.all { x -> field.type.isInstance(x) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents an exclusion criterion that exclude results containing certain values.
|
|
||||||
*
|
|
||||||
* @param T The type of value being excluded from the search.
|
|
||||||
* @property values The set of values that should be excluded from the search results.
|
|
||||||
*
|
|
||||||
* ### Example Usage:
|
|
||||||
* ```kotlin
|
|
||||||
* val excludeTag = QueryCriteria.Exclude(SearchableField.TAG, setOf(MangaTag(key, title, source)))
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
public data class Exclude<T : Any>(
|
|
||||||
public override val field: SearchableField,
|
|
||||||
@JvmField public val values: Set<T>,
|
|
||||||
) : QueryCriteria<T> {
|
|
||||||
|
|
||||||
init {
|
|
||||||
check(values.all { x -> field.type.isInstance(x) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a range criterion that allows search based on a range of values.
|
|
||||||
*
|
|
||||||
* @param T The type of value used in the range (must be comparable).
|
|
||||||
* @property from The starting value of the range (inclusive).
|
|
||||||
* @property to The ending value of the range (inclusive).
|
|
||||||
*
|
|
||||||
* ### Example Usage:
|
|
||||||
* ```kotlin
|
|
||||||
* val yearRange = QueryCriteria.Range(SearchableField.PUBLICATION_YEAR, 2000, 2020)
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
public data class Range<T : Comparable<T>>(
|
|
||||||
public override val field: SearchableField,
|
|
||||||
@JvmField public val from: T,
|
|
||||||
@JvmField public val to: T,
|
|
||||||
) : QueryCriteria<T> {
|
|
||||||
|
|
||||||
init {
|
|
||||||
check(field.type.isInstance(from))
|
|
||||||
check(field.type.isInstance(to))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a match criterion that search results based on an exact match of a value.
|
|
||||||
*
|
|
||||||
* @param T The type of value being matched.
|
|
||||||
* @property value The exact value that must be matched.
|
|
||||||
*
|
|
||||||
* ### Example Usage:
|
|
||||||
* ```kotlin
|
|
||||||
* val titleMatch = QueryCriteria.Match(SearchableField.TITLE, "manga title")
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
public data class Match<T : Any>(
|
|
||||||
public override val field: SearchableField,
|
|
||||||
@JvmField public val value: T,
|
|
||||||
) : QueryCriteria<T> {
|
|
||||||
|
|
||||||
init {
|
|
||||||
check(field.type.isInstance(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model.search
|
|
||||||
|
|
||||||
import kotlin.reflect.KClass
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines the search capabilities of a given field in the manga search query.
|
|
||||||
*
|
|
||||||
* @property field The searchable field that this capability applies to.
|
|
||||||
* Example values:
|
|
||||||
* - `SearchableField.TITLE_NAME` for searching by title.
|
|
||||||
* - `SearchableField.AUTHOR` for searching by author names.
|
|
||||||
* - `SearchableField.TAG` for filtering by tags.
|
|
||||||
* @property criteriaTypes The set of supported criteria types for the field.
|
|
||||||
* Example values:
|
|
||||||
* - `setOf(Include::class, Exclude::class)` selected field supports inclusion/exclusion criteria.
|
|
||||||
* - `setOf(Range::class)` selected field support numerical range criteria.
|
|
||||||
* @property isMultiValue Indicates whether the field supports multiple values.
|
|
||||||
* - `true` if multiple values can be provided (e.g., multiple tags or authors).
|
|
||||||
* - `false` if only a single value is allowed (e.g., only one tag or author).
|
|
||||||
* @property isExclusive Specifies whether the field can be used alongside other criteria.
|
|
||||||
* - `true` if this field can be used with other search criteria.
|
|
||||||
* - `false` if using this field requires it to be the only criterion in query.
|
|
||||||
*/
|
|
||||||
@Deprecated("Too complex")
|
|
||||||
public data class SearchCapability(
|
|
||||||
/** The searchable field that this capability applies to. */
|
|
||||||
@JvmField public val field: SearchableField,
|
|
||||||
/** The set of supported criteria types for this field. */
|
|
||||||
@JvmField public val criteriaTypes: Set<KClass<out QueryCriteria<*>>>,
|
|
||||||
/** Indicates whether the field supports multiple values. */
|
|
||||||
@JvmField public val isMultiple: Boolean,
|
|
||||||
/** Specifies whether the field can be used alongside other criteria. */
|
|
||||||
@JvmField public val isExclusive: Boolean = false,
|
|
||||||
)
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model.search
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents the various fields that can be used for searching manga.
|
|
||||||
* Each field is associated with a specific data type that defines its expected values.
|
|
||||||
*
|
|
||||||
* @property type The Java class representing the expected type of values for this field.
|
|
||||||
*/
|
|
||||||
@Deprecated("Too complex")
|
|
||||||
public enum class SearchableField(public val type: Class<*>) {
|
|
||||||
TITLE_NAME(String::class.java),
|
|
||||||
TAG(MangaTag::class.java),
|
|
||||||
AUTHOR(MangaTag::class.java),
|
|
||||||
LANGUAGE(Locale::class.java),
|
|
||||||
ORIGINAL_LANGUAGE(Locale::class.java),
|
|
||||||
STATE(MangaState::class.java),
|
|
||||||
CONTENT_TYPE(ContentType::class.java),
|
|
||||||
CONTENT_RATING(ContentRating::class.java),
|
|
||||||
DEMOGRAPHIC(Demographic::class.java),
|
|
||||||
PUBLICATION_YEAR(Int::class.javaObjectType);
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.network
|
|
||||||
|
|
||||||
import okhttp3.CookieJar
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import java.net.HttpURLConnection.HTTP_FORBIDDEN
|
|
||||||
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
|
|
||||||
|
|
||||||
public object CloudFlareHelper {
|
|
||||||
|
|
||||||
public const val PROTECTION_NOT_DETECTED: Int = 0
|
|
||||||
public const val PROTECTION_CAPTCHA: Int = 1
|
|
||||||
public const val PROTECTION_BLOCKED: Int = 2
|
|
||||||
|
|
||||||
private const val CF_CLEARANCE = "cf_clearance"
|
|
||||||
|
|
||||||
public fun checkResponseForProtection(response: Response): Int {
|
|
||||||
if (response.code != HTTP_FORBIDDEN && response.code != HTTP_UNAVAILABLE) {
|
|
||||||
return PROTECTION_NOT_DETECTED
|
|
||||||
}
|
|
||||||
val content = try {
|
|
||||||
response.peekBody(Long.MAX_VALUE).use {
|
|
||||||
Jsoup.parse(it.byteStream(), Charsets.UTF_8.name(), response.request.url.toString())
|
|
||||||
}
|
|
||||||
} catch (_: IllegalStateException) {
|
|
||||||
return PROTECTION_NOT_DETECTED
|
|
||||||
}
|
|
||||||
return when {
|
|
||||||
content.selectFirst("h2[data-translate=\"blocked_why_headline\"]") != null -> PROTECTION_BLOCKED
|
|
||||||
content.getElementById("challenge-error-title") != null || content.getElementById("challenge-error-text") != null -> PROTECTION_CAPTCHA
|
|
||||||
|
|
||||||
else -> PROTECTION_NOT_DETECTED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun getClearanceCookie(cookieJar: CookieJar, url: String): String? {
|
|
||||||
return cookieJar.loadForRequest(url.toHttpUrl()).find { it.name == CF_CLEARANCE }?.value
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun isCloudFlareCookie(name: String): Boolean {
|
|
||||||
return name.startsWith("cf_")
|
|
||||||
|| name.startsWith("_cf")
|
|
||||||
|| name.startsWith("__cf")
|
|
||||||
|| name == "csrftoken"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,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)"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue