├── src ├── main │ ├── resources │ │ ├── application.properties │ │ └── logback.xml │ └── java │ │ └── io │ │ └── github │ │ └── imsejin │ │ └── dl │ │ └── lezhin │ │ ├── attribute │ │ ├── Attribute.java │ │ └── impl │ │ │ ├── AccessToken.java │ │ │ ├── Authentication.java │ │ │ ├── DirectoryPath.java │ │ │ ├── PurchasedEpisodes.java │ │ │ ├── HttpHosts.java │ │ │ └── Content.java │ │ ├── annotation │ │ └── ProcessSpecification.java │ │ ├── exception │ │ ├── ProcessorCreationException.java │ │ ├── DuplicatedArgumentException.java │ │ ├── ImageCountNotFoundException.java │ │ ├── ProcessorNotSpecifyException.java │ │ ├── ChromeDriverNotFoundException.java │ │ ├── WebBrowserNotRunningException.java │ │ ├── InvalidConfigurationFileException.java │ │ ├── ParsingArgumentException.java │ │ ├── ConfigurationFileNotFoundException.java │ │ ├── DirectoryCreationException.java │ │ ├── InvalidProcessSpecificationException.java │ │ ├── AccessTokenNotFoundException.java │ │ ├── URLConfigurationNotFoundException.java │ │ ├── LezhinComicsDownloaderException.java │ │ └── LoginException.java │ │ ├── argument │ │ ├── Argument.java │ │ ├── impl │ │ │ ├── DebugMode.java │ │ │ ├── SingleThreading.java │ │ │ ├── BooleanArgument.java │ │ │ ├── ContentName.java │ │ │ ├── ImageFormat.java │ │ │ ├── Language.java │ │ │ └── EpisodeRange.java │ │ └── ArgumentsParser.java │ │ ├── process │ │ ├── Processor.java │ │ ├── impl │ │ │ ├── PurchasedEpisodesProcessor.java │ │ │ ├── LocaleSelectionProcessor.java │ │ │ ├── DirectoryCreationProcessor.java │ │ │ ├── ConfigurationFileProcessor.java │ │ │ ├── HttpHostsProcessor.java │ │ │ ├── AccessTokenProcessor.java │ │ │ ├── ContentInformationProcessor.java │ │ │ └── LoginProcessor.java │ │ └── framework │ │ │ ├── ProcessorCreator.java │ │ │ └── ProcessorOrderResolver.java │ │ ├── api │ │ ├── auth │ │ │ ├── model │ │ │ │ ├── ServiceRequest.java │ │ │ │ └── Authority.java │ │ │ └── service │ │ │ │ └── AuthorityService.java │ │ ├── BaseService.java │ │ ├── purchase │ │ │ └── service │ │ │ │ └── PurchasedEpisodeService.java │ │ └── image │ │ │ └── service │ │ │ └── EpisodeImageCountService.java │ │ ├── common │ │ ├── Loggers.java │ │ └── PropertyBinder.java │ │ ├── util │ │ ├── PathUtils.java │ │ └── FileNameUtils.java │ │ ├── browser │ │ └── ChromeOption.java │ │ ├── http │ │ ├── url │ │ │ └── URIs.java │ │ └── interceptor │ │ │ └── FabricatedHeadersInterceptor.java │ │ └── Application.java └── test │ ├── groovy │ └── io │ │ └── github │ │ └── imsejin │ │ └── dl │ │ └── lezhin │ │ ├── browser │ │ ├── ChromeOptionSpec.groovy │ │ └── WebBrowserSpec.groovy │ │ ├── process │ │ ├── impl │ │ │ ├── LocaleSelectionProcessorSpec.groovy │ │ │ ├── DirectoryCreationProcessorSpec.groovy │ │ │ ├── AccessTokenProcessorSpec.groovy │ │ │ └── ConfigurationFileProcessorSpec.groovy │ │ ├── framework │ │ │ ├── ProcessorCreatorSpec.groovy │ │ │ └── ProcessorOrderResolverSpec.groovy │ │ └── ProcessContextSpec.groovy │ │ ├── api │ │ └── auth │ │ │ └── service │ │ │ └── AuthorityServiceSpec.groovy │ │ ├── util │ │ └── FileNameUtilsSpec.groovy │ │ ├── argument │ │ ├── impl │ │ │ └── EpisodeRangeSpec.groovy │ │ └── ArgumentsParserSpec.groovy │ │ └── common │ │ └── PropertyBinderSpec.groovy │ ├── java │ └── io │ │ └── github │ │ └── imsejin │ │ └── dl │ │ └── lezhin │ │ └── ProgressBarTest.java │ └── resources │ └── json │ └── ko-christmas_in_the_elevator.json ├── asset ├── preview.gif ├── comic-name.png └── lezhin-comics-downloader-logo.png ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── .github ├── renovate.json5 ├── workflows │ ├── codeql.yml │ ├── create-release.yml │ └── maven-build.yml └── stale.yml ├── config.ini ├── .gitignore ├── github-imsejin-style-intellij-formatter.xml ├── README.md └── CHANGELOG.md /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | version=${project.version} 2 | environment=${env} 3 | -------------------------------------------------------------------------------- /asset/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImSejin/lezhin-comics-downloader/HEAD/asset/preview.gif -------------------------------------------------------------------------------- /asset/comic-name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImSejin/lezhin-comics-downloader/HEAD/asset/comic-name.png -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImSejin/lezhin-comics-downloader/HEAD/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /asset/lezhin-comics-downloader-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImSejin/lezhin-comics-downloader/HEAD/asset/lezhin-comics-downloader-logo.png -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/attribute/Attribute.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.attribute; 2 | 3 | import io.github.imsejin.dl.lezhin.process.ProcessContext; 4 | 5 | /** 6 | * Marker interface which indicates that 7 | * it will be recognized as a attribute of {@link ProcessContext}. 8 | * 9 | * @since 3.0.0 10 | */ 11 | public interface Attribute { 12 | } 13 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | // The :xyz naming convention (with : prefix) is shorthand for the default: presets. 5 | // For example: :xyz is the same as default:xyz. 6 | // (See https://docs.renovatebot.com/config-presets/#example-configs) 7 | "config:base", 8 | ":autodetectPinVersions", 9 | ":automergeDisabled", 10 | ":timezone(Asia/Seoul)", 11 | ], 12 | "enabled": true, 13 | "schedule": [ 14 | "before 7am on Sunday" 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/browser/ChromeOptionSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.browser 2 | 3 | import spock.lang.Specification 4 | import spock.lang.Subject 5 | 6 | @Subject(ChromeOption) 7 | class ChromeOptionSpec extends Specification { 8 | 9 | def "Checks if argument string is valid"() { 10 | given: 11 | def arguments = ChromeOption.arguments 12 | 13 | expect: 14 | arguments.unique(false) == arguments 15 | arguments.every { it.matches('^--[a-z]+(-[a-z]+)*(=.+)?$') } 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/attribute/impl/AccessToken.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.attribute.impl; 2 | 3 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.ToString; 7 | 8 | import java.util.UUID; 9 | 10 | /** 11 | * @since 3.0.0 12 | */ 13 | @Getter 14 | @ToString 15 | @EqualsAndHashCode 16 | public final class AccessToken implements Attribute { 17 | 18 | private final UUID value; 19 | 20 | public AccessToken(String value) { 21 | this.value = UUID.fromString(value); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/annotation/ProcessSpecification.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.annotation; 2 | 3 | import java.lang.annotation.Documented; 4 | import java.lang.annotation.ElementType; 5 | import java.lang.annotation.Retention; 6 | import java.lang.annotation.RetentionPolicy; 7 | import java.lang.annotation.Target; 8 | 9 | /** 10 | * @since 3.0.0 11 | */ 12 | @Documented 13 | @Target(ElementType.TYPE) 14 | @Retention(RetentionPolicy.RUNTIME) 15 | public @interface ProcessSpecification { 16 | 17 | Class INDEPENDENT = void.class; 18 | 19 | Class dependsOn() default void.class; 20 | 21 | // boolean async() default false; 22 | 23 | } 24 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | ; Copyright 2020 Sejin Im 2 | ; 3 | ; Licensed under the Apache License, Version 2.0 (the "License"); 4 | ; you may not use this file except in compliance with the License. 5 | ; You may obtain a copy of the License at 6 | ; 7 | ; http://www.apache.org/licenses/LICENSE-2.0 8 | ; 9 | ; Unless required by applicable law or agreed to in writing, software 10 | ; distributed under the License is distributed on an "AS IS" BASIS, 11 | ; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | ; See the License for the specific language governing permissions and 13 | ; limitations under the License. 14 | 15 | ; Input your lezhin comics account. 16 | [account] 17 | username = USERNAME 18 | password = PASSWORD 19 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/attribute/impl/Authentication.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.attribute.impl; 2 | 3 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 4 | import lombok.AllArgsConstructor; 5 | import lombok.EqualsAndHashCode; 6 | import lombok.Getter; 7 | import lombok.ToString; 8 | 9 | /** 10 | * @since 3.0.0 11 | */ 12 | @Getter 13 | @ToString 14 | @EqualsAndHashCode 15 | @AllArgsConstructor 16 | public final class Authentication implements Attribute { 17 | 18 | private final String username; 19 | 20 | private String password; 21 | 22 | public void erasePassword() { 23 | if (!"".equals(this.password)) { 24 | this.password = ""; 25 | } 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/attribute/impl/DirectoryPath.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.attribute.impl; 2 | 3 | import io.github.imsejin.common.assertion.Asserts; 4 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 5 | import lombok.EqualsAndHashCode; 6 | import lombok.Getter; 7 | import lombok.ToString; 8 | 9 | import java.nio.file.Path; 10 | 11 | /** 12 | * @since 3.0.0 13 | */ 14 | @Getter 15 | @ToString 16 | @EqualsAndHashCode 17 | public final class DirectoryPath implements Attribute { 18 | 19 | private final Path value; 20 | 21 | public DirectoryPath(Path path) { 22 | Asserts.that(path) 23 | .isNotNull() 24 | .isDirectory(); 25 | 26 | this.value = path; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | \t%msg%n 6 | 7 | 8 | 9 | 10 | %d{yyyy-MM-dd HH:mm:ss.SSS} %highlight([%-5level]) %magenta(%-3line) --- [%thread{10}] %cyan(%class{20}#%method) : %msg%n 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/ProcessorCreationException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class ProcessorCreationException extends LezhinComicsDownloaderException { 20 | 21 | public ProcessorCreationException(String format, Object... args) { 22 | super(format, args); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/DuplicatedArgumentException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class DuplicatedArgumentException extends LezhinComicsDownloaderException { 20 | 21 | public DuplicatedArgumentException(String format, Object... args) { 22 | super(format, args); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/ImageCountNotFoundException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class ImageCountNotFoundException extends RuntimeException { 20 | 21 | public ImageCountNotFoundException(String format, Object... args) { 22 | super(String.format(format, args)); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/ProcessorNotSpecifyException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class ProcessorNotSpecifyException extends LezhinComicsDownloaderException { 20 | 21 | public ProcessorNotSpecifyException(String format, Object... args) { 22 | super(format, args); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/ChromeDriverNotFoundException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class ChromeDriverNotFoundException extends RuntimeException { 20 | 21 | public ChromeDriverNotFoundException(String format, Object... args) { 22 | super(String.format(format, args)); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/WebBrowserNotRunningException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class WebBrowserNotRunningException extends RuntimeException { 20 | 21 | public WebBrowserNotRunningException(String format, Object... args) { 22 | super(String.format(format, args)); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/InvalidConfigurationFileException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class InvalidConfigurationFileException extends LezhinComicsDownloaderException { 20 | 21 | public InvalidConfigurationFileException(String format, Object... args) { 22 | super(format, args); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/ParsingArgumentException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class ParsingArgumentException extends LezhinComicsDownloaderException { 20 | 21 | public ParsingArgumentException(Throwable cause, String format, Object... args) { 22 | super(cause, format, args); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "dev" ] 6 | pull_request: 7 | branches: [ "dev" ] 8 | schedule: 9 | - cron: "19 12 * * 5" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ java ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v3 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v3 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/ConfigurationFileNotFoundException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class ConfigurationFileNotFoundException extends LezhinComicsDownloaderException { 20 | 21 | public ConfigurationFileNotFoundException(String format, Object... args) { 22 | super(format, args); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/DirectoryCreationException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class DirectoryCreationException extends LezhinComicsDownloaderException { 20 | 21 | public DirectoryCreationException(Throwable cause, String format, Object... args) { 22 | super(cause, format, args); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/InvalidProcessSpecificationException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class InvalidProcessSpecificationException extends LezhinComicsDownloaderException { 20 | 21 | public InvalidProcessSpecificationException(String format, Object... args) { 22 | super(format, args); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale. 4 | daysUntilStale: 90 5 | 6 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 7 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 8 | daysUntilClose: 180 9 | 10 | # Issues with these labels will never be considered stale. 11 | exemptLabels: 12 | - pinned 13 | - security 14 | 15 | # Label to use when marking an issue as stale. 16 | staleLabel: "🗑 stale" 17 | 18 | # Comment to post when marking an issue as stale. Set to `false` to disable. 19 | markComment: > 20 | This issue has been automatically marked as stale because it has not had 21 | recent activity. It will be closed if no further activity occurs. Thank you 22 | for your contributions. 23 | 24 | # Comment to post when closing a stale issue. Set to `false` to disable. 25 | closeComment: false 26 | 27 | # Limit to only `issues` or `pulls`. 28 | only: issues 29 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip 18 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar 19 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/AccessTokenNotFoundException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class AccessTokenNotFoundException extends LezhinComicsDownloaderException { 20 | 21 | public AccessTokenNotFoundException(String format, Object... args) { 22 | super(format, args); 23 | } 24 | 25 | public AccessTokenNotFoundException(Throwable cause, String format, Object... args) { 26 | super(cause, format, args); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/URLConfigurationNotFoundException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public class URLConfigurationNotFoundException extends LezhinComicsDownloaderException { 20 | 21 | public URLConfigurationNotFoundException(String format, Object... args) { 22 | super(format, args); 23 | } 24 | 25 | public URLConfigurationNotFoundException(Throwable cause, String format, Object... args) { 26 | super(cause, format, args); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/LezhinComicsDownloaderException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | public abstract class LezhinComicsDownloaderException extends Exception { 20 | 21 | protected LezhinComicsDownloaderException(String format, Object... args) { 22 | super(String.format(format, args)); 23 | } 24 | 25 | protected LezhinComicsDownloaderException(Throwable cause, String format, Object... args) { 26 | super(String.format(format, args), cause); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/argument/Argument.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.argument; 18 | 19 | import org.apache.commons.cli.Option; 20 | 21 | /** 22 | * @since 3.0.0 23 | */ 24 | public abstract class Argument { 25 | 26 | public abstract Object getValue(); 27 | 28 | // Only used by ArgumentsParser -------------------------------------------------------------------- 29 | 30 | protected abstract Option getOption(); 31 | 32 | protected abstract void validate(String value); 33 | 34 | protected abstract void setValue(String value); 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/process/Processor.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.process; 18 | 19 | import io.github.imsejin.dl.lezhin.exception.LezhinComicsDownloaderException; 20 | 21 | /** 22 | * Handler of its own process. 23 | * 24 | * @since 3.0.0 25 | */ 26 | public interface Processor { 27 | 28 | /** 29 | * Performs a process. 30 | * 31 | * @param context process context 32 | * @return result of the process 33 | * @throws LezhinComicsDownloaderException if the process failed 34 | */ 35 | Object process(ProcessContext context) throws LezhinComicsDownloaderException; 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/api/auth/model/ServiceRequest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.api.auth.model; 18 | 19 | import lombok.EqualsAndHashCode; 20 | import lombok.Getter; 21 | import lombok.Setter; 22 | import lombok.ToString; 23 | import org.jetbrains.annotations.NotNull; 24 | 25 | /** 26 | * @since 3.0.0 27 | */ 28 | @Getter 29 | @Setter 30 | @ToString 31 | @EqualsAndHashCode 32 | public class ServiceRequest { 33 | 34 | @NotNull 35 | private Long contentId; 36 | 37 | @NotNull 38 | private Long episodeId; 39 | 40 | private boolean purchased; 41 | 42 | private int q = 30; 43 | 44 | private Character firstCheckType; 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/attribute/impl/PurchasedEpisodes.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.attribute.impl; 18 | 19 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 20 | import lombok.EqualsAndHashCode; 21 | import lombok.Getter; 22 | import lombok.RequiredArgsConstructor; 23 | import lombok.ToString; 24 | 25 | import java.util.List; 26 | 27 | /** 28 | * @since 3.0.0 29 | */ 30 | @Getter 31 | @ToString 32 | @EqualsAndHashCode 33 | @RequiredArgsConstructor 34 | public class PurchasedEpisodes implements Attribute { 35 | 36 | private final List idList; 37 | 38 | public boolean contains(Long episodeId) { 39 | return this.idList.contains(episodeId); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/argument/impl/DebugMode.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.argument.impl; 18 | 19 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 20 | import org.apache.commons.cli.Option; 21 | 22 | /** 23 | * @since 3.0.0 24 | */ 25 | public class DebugMode extends BooleanArgument implements Attribute { 26 | 27 | @Override 28 | protected Option getOption() { 29 | return Option.builder("d") 30 | .longOpt("debug") 31 | .optionalArg(true) 32 | .numberOfArgs(1) 33 | .valueSeparator() 34 | .argName("true/false") 35 | .desc("Debugging mode to show browser and print more logs") 36 | .build(); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/argument/impl/SingleThreading.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.argument.impl; 18 | 19 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 20 | import org.apache.commons.cli.Option; 21 | 22 | /** 23 | * @since 3.1.0 24 | */ 25 | public class SingleThreading extends BooleanArgument implements Attribute { 26 | 27 | @Override 28 | protected Option getOption() { 29 | return Option.builder("s") 30 | .longOpt("single-threading") 31 | .optionalArg(true) 32 | .numberOfArgs(1) 33 | .valueSeparator() 34 | .argName("true/false") 35 | .desc("Download each image in the episode on single-thread") 36 | .build(); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/process/impl/LocaleSelectionProcessorSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.process.impl 2 | 3 | import io.github.imsejin.dl.lezhin.argument.impl.Language 4 | import io.github.imsejin.dl.lezhin.browser.WebBrowser 5 | import io.github.imsejin.dl.lezhin.process.ProcessContext 6 | import org.mockito.MockedStatic 7 | import spock.lang.Specification 8 | import spock.lang.Subject 9 | 10 | import static org.mockito.Mockito.mockStatic 11 | import static org.mockito.Mockito.when 12 | 13 | @Subject(LocaleSelectionProcessor) 14 | class LocaleSelectionProcessorSpec extends Specification { 15 | 16 | private ProcessContext context 17 | 18 | private MockedStatic webBrowser 19 | 20 | void setup() { 21 | webBrowser = mockStatic(WebBrowser) 22 | context = ProcessContext.create() 23 | context.add(new Language(value: "en")) 24 | } 25 | 26 | void cleanup() { 27 | webBrowser.close() 28 | } 29 | 30 | // ------------------------------------------------------------------------------------------------- 31 | 32 | def "Succeeds"() { 33 | given: 34 | with(WebBrowser) { 35 | when(request("/en/locale/en-US")).then({}) 36 | } 37 | 38 | when: 39 | def processor = new LocaleSelectionProcessor() 40 | processor.process(context) 41 | 42 | then: 43 | noExceptionThrown() 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/process/impl/DirectoryCreationProcessorSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.process.impl 2 | 3 | import io.github.imsejin.dl.lezhin.attribute.impl.Content 4 | import io.github.imsejin.dl.lezhin.attribute.impl.Content.Artist 5 | import io.github.imsejin.dl.lezhin.attribute.impl.Content.Display 6 | import io.github.imsejin.dl.lezhin.process.ProcessContext 7 | import spock.lang.Specification 8 | import spock.lang.Subject 9 | import spock.lang.TempDir 10 | 11 | import java.nio.file.Path 12 | 13 | @Subject(DirectoryCreationProcessor) 14 | class DirectoryCreationProcessorSpec extends Specification { 15 | 16 | @TempDir 17 | private Path basePath 18 | 19 | private ProcessContext context 20 | 21 | void setup() { 22 | context = ProcessContext.create() 23 | context.add(new Content( 24 | display: new Display(title: "Foo Bar", synopsis: "..."), 25 | artists: [ 26 | new Artist(id: "foo", name: "Foo", role: "writer"), 27 | new Artist(id: "bar", name: "Bar", role: "illustrator"), 28 | ], 29 | )) 30 | } 31 | 32 | // ------------------------------------------------------------------------------------------------- 33 | 34 | def "Succeeds"() { 35 | when: 36 | def processor = new DirectoryCreationProcessor(basePath) 37 | def directoryPath = processor.process(context) 38 | 39 | then: 40 | directoryPath.value == basePath.resolve("L_Foo Bar - Foo, Bar") 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create new release 2 | 3 | on: 4 | push: 5 | # When a version tag is pushed. 6 | tags: 7 | - '[0-9]+.[0-9]+.[0-9]+' 8 | 9 | jobs: 10 | create-release: 11 | name: Create a release 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up JDK 11 21 | uses: actions/setup-java@v4 22 | with: 23 | java-version: 11 24 | distribution: 'zulu' 25 | 26 | - name: Build with Maven 27 | run: mvn clean package -B --file pom.xml -Dmaven.test.skip=true 28 | 29 | # https://stackoverflow.com/questions/57819539/github-actions-how-to-share-a-calculated-value-between-job-steps 30 | - name: Set release body 31 | id: set_release_body 32 | run: | 33 | TAG='${{ github.ref_name }}' 34 | TAG_ID=$(echo $TAG | sed 's/\.//g') 35 | echo "RELEASE_BODY=[See changelog](https://github.com/ImSejin/lezhin-comics-downloader/blob/dev/CHANGELOG.md#v$TAG_ID)" >> $GITHUB_OUTPUT 36 | 37 | - name: Attach artifacts 38 | uses: ncipollo/release-action@v1 39 | with: 40 | name: v${{ github.ref_name }} 41 | draft: false 42 | prerelease: true 43 | makeLatest: false 44 | skipIfReleaseExists: true # If `draft` is true, ineffective. 45 | artifactErrorsFailBuild: true 46 | body: ${{ steps.set_release_body.outputs.RELEASE_BODY }} 47 | artifacts: 'target/lezhin-comics-downloader-*.jar' 48 | artifactContentType: 'application/java-archive' 49 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/api/auth/service/AuthorityServiceSpec.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.api.auth.service 18 | 19 | import io.github.imsejin.dl.lezhin.api.auth.model.ServiceRequest 20 | import spock.lang.Specification 21 | import spock.lang.Subject 22 | 23 | @Subject(AuthorityService) 24 | class AuthorityServiceSpec extends Specification { 25 | 26 | def "Gets authority for viewing episode"() { 27 | given: 28 | def service = new AuthorityService(Locale.KOREA, new UUID(0, 0)) 29 | 30 | when: 31 | // First episode doesn't need access token in HTTP header. 32 | // https://www.lezhin.com/ko/comic/bff/p1 33 | def request = new ServiceRequest(contentId: 5474379383439360, episodeId: 6310446659534848, firstCheckType: 'P' as char) 34 | def authority = service.getAuthForViewEpisode(request) 35 | 36 | then: 37 | authority != null 38 | authority.policy ==~ /[0-9A-Za-z]+/ 39 | authority.signature ==~ /[0-9A-Za-z_~-]+/ 40 | authority.keyPairId ==~ /[0-9A-Z]+/ 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/argument/impl/BooleanArgument.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.argument.impl; 18 | 19 | import io.github.imsejin.common.assertion.Asserts; 20 | import io.github.imsejin.dl.lezhin.argument.Argument; 21 | import lombok.EqualsAndHashCode; 22 | import lombok.Getter; 23 | 24 | /** 25 | * @since 3.0.0 26 | */ 27 | @Getter 28 | @EqualsAndHashCode(callSuper = false) 29 | abstract class BooleanArgument extends Argument { 30 | 31 | private Boolean value; 32 | 33 | @Override 34 | protected void validate(String value) { 35 | Asserts.that(value) 36 | .describedAs("Invalid {0}.value: {1}", getClass().getSimpleName(), value) 37 | .isNotNull() 38 | .isNotEmpty() 39 | .matches("true|false"); 40 | } 41 | 42 | @Override 43 | protected void setValue(String value) { 44 | this.value = Boolean.parseBoolean(value); 45 | } 46 | 47 | @Override 48 | public String toString() { 49 | return getClass().getSimpleName() + "(value=" + value + ')'; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/common/Loggers.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.common; 18 | 19 | import io.github.imsejin.common.annotation.ExcludeFromGeneratedJacocoReport; 20 | import org.slf4j.Logger; 21 | import org.slf4j.LoggerFactory; 22 | 23 | import javax.annotation.concurrent.ThreadSafe; 24 | 25 | /** 26 | * @since 2.7.0 27 | */ 28 | @ThreadSafe 29 | public final class Loggers { 30 | 31 | private static String loggerName = "NormalLogger"; 32 | 33 | @ExcludeFromGeneratedJacocoReport 34 | private Loggers() { 35 | throw new UnsupportedOperationException(getClass().getName() + " is not allowed to instantiate"); 36 | } 37 | 38 | public static void debugging() { 39 | loggerName = "DebugLogger"; 40 | } 41 | 42 | public static Logger getLogger() { 43 | return SingletonLazyHolder.LOGGER; 44 | } 45 | 46 | // ------------------------------------------------------------------------------------------------- 47 | 48 | private static class SingletonLazyHolder { 49 | private static final Logger LOGGER; 50 | 51 | static { 52 | LOGGER = LoggerFactory.getLogger(loggerName); 53 | } 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/process/framework/ProcessorCreatorSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.process.framework 2 | 3 | import io.github.imsejin.dl.lezhin.process.impl.AccessTokenProcessor 4 | import io.github.imsejin.dl.lezhin.process.impl.ConfigurationFileProcessor 5 | import io.github.imsejin.dl.lezhin.process.impl.ContentInformationProcessor 6 | import io.github.imsejin.dl.lezhin.process.impl.DirectoryCreationProcessor 7 | import io.github.imsejin.dl.lezhin.process.impl.DownloadProcessor 8 | import io.github.imsejin.dl.lezhin.process.impl.HttpHostsProcessor 9 | import io.github.imsejin.dl.lezhin.process.impl.LocaleSelectionProcessor 10 | import io.github.imsejin.dl.lezhin.process.impl.LoginProcessor 11 | import io.github.imsejin.dl.lezhin.process.impl.PurchasedEpisodesProcessor 12 | import spock.lang.Specification 13 | import spock.lang.Subject 14 | 15 | import java.nio.file.Path 16 | 17 | @Subject(ProcessorCreator) 18 | class ProcessorCreatorSpec extends Specification { 19 | 20 | def "Returns empty processors"() { 21 | given: 22 | def creator = new ProcessorCreator() 23 | 24 | when: 25 | def processors = creator.create([]) 26 | 27 | then: 28 | processors == [] 29 | } 30 | 31 | def "Creates processors"() { 32 | given: 33 | def beans = [Path].collect { Mock(it) } 34 | def types = [ConfigurationFileProcessor, LoginProcessor, HttpHostsProcessor, AccessTokenProcessor, 35 | LocaleSelectionProcessor, ContentInformationProcessor, PurchasedEpisodesProcessor, 36 | DirectoryCreationProcessor, DownloadProcessor] 37 | 38 | when: 39 | def creator = new ProcessorCreator(beans as Object[]) 40 | def processors = creator.create(types) 41 | 42 | then: 43 | processors*.class == types 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/process/impl/PurchasedEpisodesProcessor.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.process.impl; 18 | 19 | import io.github.imsejin.dl.lezhin.annotation.ProcessSpecification; 20 | import io.github.imsejin.dl.lezhin.api.purchase.service.PurchasedEpisodeService; 21 | import io.github.imsejin.dl.lezhin.attribute.impl.PurchasedEpisodes; 22 | import io.github.imsejin.dl.lezhin.exception.LezhinComicsDownloaderException; 23 | import io.github.imsejin.dl.lezhin.process.ProcessContext; 24 | import io.github.imsejin.dl.lezhin.process.Processor; 25 | 26 | import java.util.List; 27 | 28 | /** 29 | * Processor for extraction of list of purchased episode 30 | * 31 | * @since 3.0.0 32 | */ 33 | @ProcessSpecification(dependsOn = ContentInformationProcessor.class) 34 | public class PurchasedEpisodesProcessor implements Processor { 35 | 36 | @Override 37 | public PurchasedEpisodes process(ProcessContext context) throws LezhinComicsDownloaderException { 38 | PurchasedEpisodeService service = new PurchasedEpisodeService(context.getLanguage(), context.getAccessToken().getValue()); 39 | List idList = service.getPurchasedEpisodeIdList(context.getContent().getAlias()); 40 | 41 | return new PurchasedEpisodes(idList); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/browser/WebBrowserSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.browser 2 | 3 | import io.github.imsejin.dl.lezhin.exception.ChromeDriverNotFoundException 4 | import io.github.imsejin.dl.lezhin.util.PathUtils 5 | import org.mockito.MockedStatic 6 | import spock.lang.Specification 7 | import spock.lang.Subject 8 | import spock.lang.TempDir 9 | 10 | import java.nio.file.Path 11 | 12 | import static org.mockito.Mockito.mockStatic 13 | import static org.mockito.Mockito.when 14 | 15 | @Subject(WebBrowser) 16 | class WebBrowserSpec extends Specification { 17 | 18 | @TempDir 19 | private Path basePath 20 | 21 | private MockedStatic pathUtils 22 | 23 | void setup() { 24 | pathUtils = mockStatic(PathUtils) 25 | } 26 | 27 | void cleanup() { 28 | pathUtils.close() 29 | } 30 | 31 | // ------------------------------------------------------------------------------------------------- 32 | 33 | def "Turns on debug mode"() { 34 | given: 35 | def arguments = ChromeOption.arguments 36 | 37 | expect: 38 | WebBrowser.@arguments == arguments 39 | 40 | when: 41 | WebBrowser.debugging() 42 | 43 | then: 44 | with(ChromeOption) { 45 | def debugArgs = [HEADLESS, NO_SANDBOX, DISABLE_GPU].collect { it.argument } 46 | arguments.removeAll(debugArgs) 47 | } 48 | WebBrowser.@arguments == arguments 49 | } 50 | 51 | def "Runs"() { 52 | given: 53 | with(PathUtils) { 54 | when(getCurrentPath()).thenReturn(basePath) 55 | } 56 | 57 | when: 58 | WebBrowser.run() 59 | 60 | then: 61 | def e = thrown(ChromeDriverNotFoundException) 62 | e.message.startsWith("There is no chromedriver: ") 63 | } 64 | 65 | def "Checks if web browser is running"() { 66 | expect: 67 | !WebBrowser.running 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/util/PathUtils.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.util; 18 | 19 | import io.github.imsejin.common.annotation.ExcludeFromGeneratedJacocoReport; 20 | import io.github.imsejin.dl.lezhin.exception.DirectoryCreationException; 21 | 22 | import java.io.IOException; 23 | import java.nio.file.Files; 24 | import java.nio.file.Path; 25 | 26 | /** 27 | * Utility for path 28 | */ 29 | public final class PathUtils { 30 | 31 | @ExcludeFromGeneratedJacocoReport 32 | private PathUtils() { 33 | throw new UnsupportedOperationException(getClass().getName() + " is not allowed to instantiate"); 34 | } 35 | 36 | public static Path getCurrentPath() { 37 | try { 38 | return Path.of(".").toRealPath(); 39 | } catch (IOException e) { 40 | throw new RuntimeException(e.getMessage(), e); 41 | } 42 | } 43 | 44 | public static boolean createDirectoryIfNotExists(Path dir) throws DirectoryCreationException { 45 | if (Files.isDirectory(dir)) { 46 | return false; 47 | } 48 | 49 | try { 50 | Files.createDirectory(dir); 51 | return true; 52 | } catch (IOException e) { 53 | throw new DirectoryCreationException(e, "Failed to create directory: %s", dir); 54 | } 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/test/java/io/github/imsejin/dl/lezhin/ProgressBarTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin; 18 | 19 | import me.tongfei.progressbar.ConsoleProgressBarConsumer; 20 | import me.tongfei.progressbar.ProgressBar; 21 | import me.tongfei.progressbar.ProgressBarBuilder; 22 | import me.tongfei.progressbar.ProgressBarStyle; 23 | import org.junit.jupiter.api.Disabled; 24 | import org.junit.jupiter.api.Test; 25 | 26 | import java.util.concurrent.TimeUnit; 27 | import java.util.stream.IntStream; 28 | 29 | class ProgressBarTest { 30 | 31 | @Test 32 | @Disabled 33 | void testProgressBar() { 34 | // given 35 | ProgressBarBuilder builder = new ProgressBarBuilder(); 36 | builder.setTaskName("task-name"); 37 | builder.setInitialMax(20); 38 | builder.setUpdateIntervalMillis(50); 39 | builder.setStyle(ProgressBarStyle.COLORFUL_UNICODE_BLOCK); 40 | builder.setConsumer(new ConsoleProgressBarConsumer(System.out)); 41 | 42 | // when 43 | ProgressBar progressBar = builder.build(); 44 | IntStream.rangeClosed(1, 20).forEach(n -> { 45 | try { 46 | TimeUnit.MILLISECONDS.sleep(100); 47 | progressBar.step(); // == progressBar.stepBy(1); 48 | } catch (InterruptedException ignored) { 49 | } 50 | }); 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/argument/impl/ContentName.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.argument.impl; 18 | 19 | import io.github.imsejin.common.assertion.Asserts; 20 | import io.github.imsejin.dl.lezhin.argument.Argument; 21 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 22 | import lombok.AccessLevel; 23 | import lombok.EqualsAndHashCode; 24 | import lombok.Getter; 25 | import lombok.Setter; 26 | import lombok.ToString; 27 | import org.apache.commons.cli.Option; 28 | 29 | /** 30 | * Webtoon name 31 | * 32 | * @since 3.0.0 33 | */ 34 | @Getter 35 | @Setter(AccessLevel.PROTECTED) 36 | @ToString 37 | @EqualsAndHashCode(callSuper = false) 38 | public class ContentName extends Argument implements Attribute { 39 | 40 | private String value; 41 | 42 | @Override 43 | protected Option getOption() { 44 | return Option.builder("n") 45 | .longOpt("name") 46 | .desc("webtoon name you want to download") 47 | .required() 48 | .hasArg() 49 | .valueSeparator() 50 | .argName("webtoon_name") 51 | .build(); 52 | } 53 | 54 | @Override 55 | protected void validate(String value) { 56 | Asserts.that(value) 57 | .describedAs("Invalid ContentName.value: {0}", value) 58 | .isNotNull() 59 | .hasText(); 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/process/framework/ProcessorOrderResolverSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.process.framework 2 | 3 | import io.github.imsejin.common.util.ClassUtils 4 | import io.github.imsejin.dl.lezhin.Application 5 | import io.github.imsejin.dl.lezhin.process.Processor 6 | import io.github.imsejin.dl.lezhin.process.impl.AccessTokenProcessor 7 | import io.github.imsejin.dl.lezhin.process.impl.ConfigurationFileProcessor 8 | import io.github.imsejin.dl.lezhin.process.impl.ContentInformationProcessor 9 | import io.github.imsejin.dl.lezhin.process.impl.DirectoryCreationProcessor 10 | import io.github.imsejin.dl.lezhin.process.impl.DownloadProcessor 11 | import io.github.imsejin.dl.lezhin.process.impl.HttpHostsProcessor 12 | import io.github.imsejin.dl.lezhin.process.impl.LocaleSelectionProcessor 13 | import io.github.imsejin.dl.lezhin.process.impl.LoginProcessor 14 | import io.github.imsejin.dl.lezhin.process.impl.PurchasedEpisodesProcessor 15 | import org.reflections.Reflections 16 | import spock.lang.Specification 17 | import spock.lang.Subject 18 | 19 | @Subject(ProcessorOrderResolver) 20 | class ProcessorOrderResolverSpec extends Specification { 21 | 22 | def "Resolves the order of process types"() { 23 | given: 24 | def processorTypes = new Reflections(Application.class).getSubTypesOf(Processor.class) 25 | .findAll { !ClassUtils.isAbstractClass(it) && it.enclosingClass == null } 26 | 27 | when: 28 | def orderedTypes = ProcessorOrderResolver.resolve(processorTypes) 29 | 30 | then: 31 | processorTypes == orderedTypes as Set 32 | orderedTypes == [ 33 | ConfigurationFileProcessor, 34 | LoginProcessor, 35 | HttpHostsProcessor, 36 | AccessTokenProcessor, 37 | LocaleSelectionProcessor, 38 | ContentInformationProcessor, 39 | PurchasedEpisodesProcessor, 40 | DirectoryCreationProcessor, 41 | DownloadProcessor, 42 | ] 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/util/FileNameUtilsSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.util 2 | 3 | import spock.lang.Specification 4 | import spock.lang.Subject 5 | 6 | @Subject(FileNameUtils) 7 | class FileNameUtilsSpec extends Specification { 8 | 9 | def "Replaces forbidden characters"() { 10 | when: 11 | def actual = FileNameUtils.replaceForbiddenCharacters(fileName) 12 | 13 | then: 14 | actual == expected 15 | 16 | where: 17 | fileName | expected 18 | "" | "" 19 | "alpha.log" | "alpha.log" 20 | " beta " | " beta " 21 | "" | "<gamma>" 22 | "delta:2" | "delta:2" 23 | 'He said "It\'s mine".' | "He said "It's mine"." 24 | "1/4" | "1/4" 25 | "red\\blue" | "red\blue" 26 | "black|white" | "black|white" 27 | "Why so serious?" | "Why so serious?" 28 | "* is called asterisk." | "* is called asterisk." 29 | "It is..." | "It is…" 30 | "...Wait, what!?" | "...Wait, what!?" 31 | } 32 | 33 | def "Sanitizes file name"() { 34 | when: 35 | def actual = FileNameUtils.sanitize(fileName) 36 | 37 | then: 38 | actual == expected 39 | 40 | where: 41 | fileName | expected 42 | "" | "" 43 | "\t\n\r\b" | "" 44 | " alpha . conf" | "alpha.conf" 45 | "A\u0000B" | "AB" 46 | "X\u001fY" | "XY" 47 | "const" | "const" 48 | "Con.log" | "_Con.log" 49 | "PRNT.dat" | "PRNT.dat" 50 | "PRN.dat" | "_PRN.dat" 51 | "aux2" | "aux2" 52 | " aux " | "_aux" 53 | "" | "" 54 | "NUL" | "_NUL" 55 | "Com10.ini" | "Com10.ini" 56 | "Com9.ini" | "_Com9.ini" 57 | "lPT-1.yml" | "lPT-1.yml" 58 | "lPT0.yml" | "_lPT0.yml" 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------------------------------- 2 | # Downloaded Directories 3 | # -------------------------------------------------------------------------------------------------- 4 | L_*/ 5 | 6 | # -------------------------------------------------------------------------------------------------- 7 | # Chrome Driver 8 | # -------------------------------------------------------------------------------------------------- 9 | chromedriver 10 | chromedriver.exe 11 | !src/test/**/chrome-driver/**/chromedriver* 12 | src/test/**/chrome-driver/**/chromedriver-m1-* 13 | src/test/**/google-chrome/**/googlechrome-m1-* 14 | 15 | # -------------------------------------------------------------------------------------------------- 16 | # Maven 17 | # -------------------------------------------------------------------------------------------------- 18 | target/ 19 | !.mvn/wrapper/maven-wrapper.jar 20 | 21 | # -------------------------------------------------------------------------------------------------- 22 | # Spring Tool Suite 23 | # -------------------------------------------------------------------------------------------------- 24 | .apt_generated 25 | .classpath 26 | .factorypath 27 | .project 28 | .settings 29 | .springBeans 30 | 31 | # -------------------------------------------------------------------------------------------------- 32 | # IntelliJ IDEA 33 | # -------------------------------------------------------------------------------------------------- 34 | .idea 35 | *.iws 36 | *.iml 37 | *.ipr 38 | 39 | # -------------------------------------------------------------------------------------------------- 40 | # NetBeans 41 | # -------------------------------------------------------------------------------------------------- 42 | nbproject/private/ 43 | build/ 44 | nbbuild/ 45 | dist/ 46 | nbdist/ 47 | .nb-gradle/ 48 | 49 | # -------------------------------------------------------------------------------------------------- 50 | # etc 51 | # -------------------------------------------------------------------------------------------------- 52 | bin/ 53 | .gitconfig 54 | *.log 55 | *.conf* 56 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/api/BaseService.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.api; 18 | 19 | import com.google.gson.Gson; 20 | import com.google.gson.GsonBuilder; 21 | import io.github.imsejin.dl.lezhin.http.interceptor.FabricatedHeadersInterceptor; 22 | import lombok.AccessLevel; 23 | import lombok.Getter; 24 | import okhttp3.OkHttpClient; 25 | 26 | import java.time.Duration; 27 | import java.util.Locale; 28 | import java.util.UUID; 29 | 30 | /** 31 | * @since 3.0.0 32 | */ 33 | public abstract class BaseService { 34 | 35 | private static final FabricatedHeadersInterceptor interceptor = new FabricatedHeadersInterceptor(); 36 | 37 | @Getter(AccessLevel.PROTECTED) 38 | private static final Gson gson = new GsonBuilder() 39 | .disableJdkUnsafe() 40 | // com.google.gson.stream.MalformedJsonException: 41 | // Use JsonReader.setLenient(true) to accept malformed JSON at line 1 column 1 path $ 42 | .setLenient() 43 | .create(); 44 | 45 | @Getter(AccessLevel.PROTECTED) 46 | private static final OkHttpClient httpClient = new OkHttpClient.Builder() 47 | .readTimeout(Duration.ofSeconds(15)) 48 | .writeTimeout(Duration.ofSeconds(15)) 49 | .addInterceptor(interceptor) 50 | .build(); 51 | 52 | public BaseService(Locale locale, UUID accessToken) { 53 | interceptor.setLocale(locale); 54 | interceptor.setAccessToken(accessToken); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/attribute/impl/HttpHosts.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.attribute.impl; 2 | 3 | import io.github.imsejin.common.assertion.Asserts; 4 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 5 | import lombok.EqualsAndHashCode; 6 | import lombok.Getter; 7 | import lombok.ToString; 8 | 9 | /** 10 | * Attribute for configuration of HTTP host 11 | * 12 | *
13 |  *     __LZ_CONFIG__ = _.merge(window.__LZ_CONFIG__, {
14 |  *       apiUrl: 'api.lezhin.com',
15 |  *       cdnUrl: 'https://ccdn.lezhin.com',
16 |  *       contentsCdnUrl: 'https://rcdn.lezhin.com',
17 |  *       recoUrl: 'dondog.lezhin.com',
18 |  *       payUrl: 'https://pay.lezhin.com',
19 |  *       pantherUrl: 'https://panther.lezhin.com',
20 |  *       ...
21 |  *     });
22 |  * 
23 | * 24 | * @since 3.0.0 25 | */ 26 | @Getter 27 | @ToString 28 | @EqualsAndHashCode 29 | public final class HttpHosts implements Attribute { 30 | 31 | private final String api; 32 | 33 | private final String cdn; 34 | 35 | private final String contentsCdn; 36 | 37 | private final String reco; 38 | 39 | private final String pay; 40 | 41 | private final String panther; 42 | 43 | public HttpHosts(String api, String cdn, String contentsCdn, String reco, String pay, String panther) { 44 | this.api = prependProtocolIfMissing(api); 45 | this.cdn = prependProtocolIfMissing(cdn); 46 | this.contentsCdn = prependProtocolIfMissing(contentsCdn); 47 | this.reco = prependProtocolIfMissing(reco); 48 | this.pay = prependProtocolIfMissing(pay); 49 | this.panther = prependProtocolIfMissing(panther); 50 | } 51 | 52 | private String prependProtocolIfMissing(String url) { 53 | Asserts.that(url) 54 | .isNotNull() 55 | .isNotEmpty() 56 | .hasText(); 57 | 58 | // Base URL for Retrofit must end with forward slash(/). 59 | if (!url.endsWith("/")) { 60 | url += '/'; 61 | } 62 | 63 | if (url.matches("^https?://[\\w.]+/$")) { 64 | return url; 65 | } 66 | 67 | return "https://" + url; 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/argument/impl/ImageFormat.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.argument.impl; 18 | 19 | import io.github.imsejin.common.assertion.Asserts; 20 | import io.github.imsejin.dl.lezhin.argument.Argument; 21 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 22 | import lombok.EqualsAndHashCode; 23 | import lombok.Getter; 24 | import lombok.ToString; 25 | import org.apache.commons.cli.Option; 26 | 27 | /** 28 | * @since 3.0.0 29 | */ 30 | @Getter 31 | @ToString 32 | @EqualsAndHashCode(callSuper = false) 33 | public class ImageFormat extends Argument implements Attribute { 34 | 35 | private static final String DEFAULT_VALUE = "webp"; 36 | 37 | private String value = DEFAULT_VALUE; 38 | 39 | @Override 40 | protected Option getOption() { 41 | return Option.builder("j") 42 | .longOpt("jpg") 43 | .desc("Save images as JPEG format (default: WEBP format)") 44 | .build(); 45 | } 46 | 47 | @Override 48 | protected void validate(String value) { 49 | Asserts.that(value) 50 | .describedAs("Invalid ImageFormat.value: {0}", value) 51 | .isNotNull() 52 | .isNotEmpty() 53 | .matches("true|false"); 54 | } 55 | 56 | @Override 57 | protected void setValue(String value) { 58 | switch (value) { 59 | case "true": 60 | this.value = "jpg"; 61 | break; 62 | case "false": 63 | this.value = DEFAULT_VALUE; 64 | break; 65 | } 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/common/PropertyBinder.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.common; 18 | 19 | import io.github.imsejin.dl.lezhin.api.auth.model.Authority; 20 | import io.github.imsejin.dl.lezhin.api.auth.model.ServiceRequest; 21 | import io.github.imsejin.dl.lezhin.api.auth.service.AuthorityService.AuthData; 22 | import io.github.imsejin.dl.lezhin.attribute.impl.Content; 23 | import io.github.imsejin.dl.lezhin.attribute.impl.Content.Episode; 24 | import io.github.imsejin.dl.lezhin.attribute.impl.HttpHosts; 25 | import org.mapstruct.Mapper; 26 | import org.mapstruct.Mapping; 27 | import org.mapstruct.factory.Mappers; 28 | 29 | import java.util.Map; 30 | 31 | @Mapper 32 | public interface PropertyBinder { 33 | 34 | PropertyBinder INSTANCE = Mappers.getMapper(PropertyBinder.class); 35 | 36 | Authority toAuthority(AuthData source); 37 | 38 | @Mapping(target = "contentId", source = "content.id") 39 | @Mapping(target = "episodeId", source = "episode.id") 40 | @Mapping(target = "q", ignore = true) 41 | @Mapping(target = "firstCheckType", constant = "'P'") 42 | ServiceRequest toServiceRequest(Content content, Episode episode, boolean purchased); 43 | 44 | @Mapping(target = "api", source = "config.apiUrl") 45 | @Mapping(target = "cdn", source = "config.cdnUrl") 46 | @Mapping(target = "contentsCdn", source = "config.contentsCdnUrl") 47 | @Mapping(target = "reco", source = "config.recoUrl") 48 | @Mapping(target = "pay", source = "config.payUrl") 49 | @Mapping(target = "panther", source = "config.pantherUrl") 50 | HttpHosts toHttpHosts(Map config); 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/process/impl/LocaleSelectionProcessor.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.process.impl; 18 | 19 | import io.github.imsejin.dl.lezhin.annotation.ProcessSpecification; 20 | import io.github.imsejin.dl.lezhin.argument.impl.Language; 21 | import io.github.imsejin.dl.lezhin.browser.WebBrowser; 22 | import io.github.imsejin.dl.lezhin.common.Loggers; 23 | import io.github.imsejin.dl.lezhin.exception.LezhinComicsDownloaderException; 24 | import io.github.imsejin.dl.lezhin.http.url.URIs; 25 | import io.github.imsejin.dl.lezhin.process.ProcessContext; 26 | import io.github.imsejin.dl.lezhin.process.Processor; 27 | 28 | /** 29 | * Processor for selecting locale 30 | * 31 | *

When you see the content of lezhin platform not as usual, they show you a page of locale selection. 32 | * It is a obstacle to progress a process, so this processor requests that in advance. 33 | * 34 | *

{@code
35 |  * $("#locale-form").on("submit", function (t) {
36 |  *   t.preventDefault();
37 |  *   var n = $(this).find("[name=locale]:checked").val();
38 |  *   location.href = "/".concat(e.a.get("language"), "/locale/")
39 |  *       .concat(n, "?redirect=").concat(encodeURIComponent(location.href))
40 |  * })
41 |  * }
42 | * 43 | * @since 3.0.0 44 | */ 45 | @ProcessSpecification(dependsOn = AccessTokenProcessor.class) 46 | public class LocaleSelectionProcessor implements Processor { 47 | 48 | @Override 49 | public Void process(ProcessContext context) throws LezhinComicsDownloaderException { 50 | Language language = context.getLanguage(); 51 | String localePath = URIs.LOCALE.get(language.getValue().getLanguage(), language.asLocaleString()); 52 | 53 | Loggers.getLogger().debug("Change locale setting: {}", localePath); 54 | WebBrowser.request(localePath); 55 | 56 | return null; 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/maven-build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven 3 | 4 | name: Java CI with Maven 5 | 6 | on: 7 | push: 8 | branches: 9 | - release 10 | - dev 11 | 12 | schedule: 13 | - cron: "0 20 * * 6" # Runs at 05:00 Asia/Seoul on Sun. 14 | 15 | jobs: 16 | build: 17 | name: Builds with java ${{ matrix.java }} on ${{ matrix.os }} 18 | 19 | strategy: 20 | fail-fast: true 21 | max-parallel: 3 # Sum of matrices. 22 | matrix: 23 | os: [ ubuntu-latest, macos-latest, windows-latest ] 24 | java: [ 11, 11.0.4, 17 ] 25 | 26 | runs-on: ${{ matrix.os }} 27 | 28 | env: 29 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 30 | GITHUB_WORKSPACE: ${{ github.workspace }} 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - name: Set up JDK ${{ matrix.java }} 36 | uses: actions/setup-java@v4 37 | with: 38 | java-version: ${{ matrix.java }} 39 | distribution: 'zulu' 40 | 41 | # - name: Download chrome driver 42 | # run: | 43 | # # Get latest version number. 44 | # VERSION=$(curl -s https://chromedriver.storage.googleapis.com/LATEST_RELEASE) 45 | # 46 | # # Download chromedriver for your OS. 47 | # if [[ '${{ matrix.os }}' == "ubuntu"* ]]; then 48 | # URL="https://chromedriver.storage.googleapis.com/$VERSION/chromedriver_linux64.zip" 49 | # elif [[ '${{ matrix.os }}' == "macos"* ]]; then 50 | # URL="https://chromedriver.storage.googleapis.com/$VERSION/chromedriver_mac64.zip" 51 | # elif [[ '${{ matrix.os }}' == "windows"* ]]; then 52 | # URL="https://chromedriver.storage.googleapis.com/$VERSION/chromedriver_win32.zip" 53 | # else 54 | # echo "Error: Unsupported operating system $OSTYPE" 55 | # exit 1 56 | # fi 57 | # 58 | # # Download and extract chromedriver. 59 | # curl -O $URL 60 | # unzip chromedriver*.zip 61 | # rm chromedriver*.zip 62 | 63 | - name: Build with Maven 64 | run: mvn clean package -B --file pom.xml 65 | 66 | - name: Send code coverage to Codecov 67 | uses: codecov/codecov-action@v3 68 | with: 69 | fail_ci_if_error: true 70 | verbose: true 71 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/argument/impl/Language.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.argument.impl; 18 | 19 | import io.github.imsejin.common.assertion.Asserts; 20 | import io.github.imsejin.dl.lezhin.argument.Argument; 21 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 22 | import lombok.EqualsAndHashCode; 23 | import lombok.Getter; 24 | import lombok.ToString; 25 | import org.apache.commons.cli.Option; 26 | 27 | import java.util.Locale; 28 | 29 | /** 30 | * Language of lezhin platform 31 | * 32 | * @since 3.0.0 33 | */ 34 | @Getter 35 | @ToString 36 | @EqualsAndHashCode(callSuper = false) 37 | public class Language extends Argument implements Attribute { 38 | 39 | private Locale value; 40 | 41 | @Override 42 | protected Option getOption() { 43 | return Option.builder("l") 44 | .longOpt("lang") 45 | .desc("Language of lezhin platform you want to download the webtoon on") 46 | .required() 47 | .hasArg() 48 | .valueSeparator() 49 | .argName("locale_language") 50 | .build(); 51 | } 52 | 53 | @Override 54 | protected void validate(String value) { 55 | Asserts.that(value) 56 | .describedAs("Invalid Language.value: {0}", value) 57 | .isNotNull() 58 | .isNotEmpty() 59 | .matches("ko|en|ja"); 60 | } 61 | 62 | @Override 63 | protected void setValue(String value) { 64 | switch (value) { 65 | case "ko": 66 | this.value = Locale.KOREA; 67 | break; 68 | case "en": 69 | this.value = Locale.US; 70 | break; 71 | case "ja": 72 | this.value = Locale.JAPAN; 73 | break; 74 | } 75 | } 76 | 77 | public String asLocaleString() { 78 | if (this.value == null) { 79 | return null; 80 | } 81 | 82 | return this.value.getLanguage() + '-' + this.value.getCountry(); 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/process/impl/DirectoryCreationProcessor.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.process.impl; 18 | 19 | import io.github.imsejin.dl.lezhin.annotation.ProcessSpecification; 20 | import io.github.imsejin.dl.lezhin.attribute.impl.Content.Artist; 21 | import io.github.imsejin.dl.lezhin.attribute.impl.DirectoryPath; 22 | import io.github.imsejin.dl.lezhin.common.Loggers; 23 | import io.github.imsejin.dl.lezhin.exception.DirectoryCreationException; 24 | import io.github.imsejin.dl.lezhin.process.ProcessContext; 25 | import io.github.imsejin.dl.lezhin.process.Processor; 26 | import io.github.imsejin.dl.lezhin.util.FileNameUtils; 27 | import io.github.imsejin.dl.lezhin.util.PathUtils; 28 | import lombok.RequiredArgsConstructor; 29 | 30 | import java.nio.file.Path; 31 | 32 | import static java.util.stream.Collectors.joining; 33 | 34 | /** 35 | * Processor for creating a directory of the content 36 | * 37 | * @since 3.0.0 38 | */ 39 | @RequiredArgsConstructor 40 | @ProcessSpecification(dependsOn = PurchasedEpisodesProcessor.class) 41 | public class DirectoryCreationProcessor implements Processor { 42 | 43 | private final Path basePath; 44 | 45 | @Override 46 | public DirectoryPath process(ProcessContext context) throws DirectoryCreationException { 47 | String contentTitle = context.getContent().getDisplay().getTitle(); 48 | String artists = context.getContent().getArtists().stream().map(Artist::getName) 49 | .collect(joining(", ")); 50 | 51 | String directoryName = String.format("L_%s - %s", contentTitle, artists); 52 | directoryName = FileNameUtils.sanitize(directoryName); 53 | directoryName = FileNameUtils.replaceForbiddenCharacters(directoryName); 54 | 55 | Path targetPath = this.basePath.resolve(directoryName); 56 | 57 | boolean created = PathUtils.createDirectoryIfNotExists(targetPath); 58 | if (created) { 59 | Loggers.getLogger().debug("Create directory: {}", targetPath); 60 | } 61 | 62 | return new DirectoryPath(targetPath); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/process/impl/ConfigurationFileProcessor.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.process.impl; 2 | 3 | import io.github.imsejin.common.util.IniUtils; 4 | import io.github.imsejin.common.util.StringUtils; 5 | import io.github.imsejin.dl.lezhin.annotation.ProcessSpecification; 6 | import io.github.imsejin.dl.lezhin.attribute.impl.Authentication; 7 | import io.github.imsejin.dl.lezhin.exception.ConfigurationFileNotFoundException; 8 | import io.github.imsejin.dl.lezhin.exception.InvalidConfigurationFileException; 9 | import io.github.imsejin.dl.lezhin.process.ProcessContext; 10 | import io.github.imsejin.dl.lezhin.process.Processor; 11 | import org.ini4j.Ini; 12 | 13 | import java.nio.file.Files; 14 | import java.nio.file.Path; 15 | import java.util.Map; 16 | 17 | /** 18 | * Processor for reading the configuration file 19 | * 20 | * @since 3.0.0 21 | */ 22 | @ProcessSpecification 23 | public class ConfigurationFileProcessor implements Processor { 24 | 25 | private final Path filePath; 26 | 27 | public ConfigurationFileProcessor(Path basePath) { 28 | this.filePath = basePath.resolve("config.ini"); 29 | } 30 | 31 | /** 32 | * Performs a process of authentication from configuration file. 33 | * 34 | * @param context process context 35 | * @return authentication 36 | * @throws ConfigurationFileNotFoundException if configuration file is not found 37 | * @throws InvalidConfigurationFileException if configuration file is not specified properly 38 | */ 39 | @Override 40 | public Authentication process(ProcessContext context) 41 | throws ConfigurationFileNotFoundException, InvalidConfigurationFileException { 42 | if (!Files.isRegularFile(this.filePath)) { 43 | throw new ConfigurationFileNotFoundException("There is no configuration file: %s", this.filePath); 44 | } 45 | 46 | Ini ini = IniUtils.read(this.filePath.toFile()); 47 | 48 | Map section = ini.get("account"); 49 | if (section == null) { 50 | throw new InvalidConfigurationFileException("Configuration file has no section[account]: %s", this.filePath); 51 | } 52 | 53 | String username = section.get("username"); 54 | if (StringUtils.isNullOrBlank(username)) { 55 | throw new InvalidConfigurationFileException("It is invalid value of name[username] in section[account]: %s", username); 56 | } 57 | 58 | String password = section.get("password"); 59 | if (StringUtils.isNullOrBlank(password)) { 60 | throw new InvalidConfigurationFileException("It is invalid value of name[password] in section[account]: %s", password); 61 | } 62 | 63 | return new Authentication(username, password); 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/argument/impl/EpisodeRangeSpec.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.argument.impl 18 | 19 | import spock.lang.Specification 20 | import spock.lang.Subject 21 | 22 | import static io.github.imsejin.dl.lezhin.argument.impl.EpisodeRange.RangeType.ALL 23 | import static io.github.imsejin.dl.lezhin.argument.impl.EpisodeRange.RangeType.FROM_BEGINNING 24 | import static io.github.imsejin.dl.lezhin.argument.impl.EpisodeRange.RangeType.ONE 25 | import static io.github.imsejin.dl.lezhin.argument.impl.EpisodeRange.RangeType.SOME 26 | import static io.github.imsejin.dl.lezhin.argument.impl.EpisodeRange.RangeType.TO_END 27 | 28 | @Subject(EpisodeRange) 29 | class EpisodeRangeSpec extends Specification { 30 | 31 | def "Sets the value"() { 32 | given: 33 | def episodeRange = new EpisodeRange() 34 | 35 | when: 36 | episodeRange.value = value 37 | 38 | then: 39 | episodeRange.startNumber == startNumber 40 | episodeRange.endNumber == endNumber 41 | episodeRange.rangeType == rangeType 42 | 43 | where: 44 | value || startNumber | endNumber | rangeType 45 | "" || null | null | ALL 46 | "12" || 12 | 12 | ONE 47 | "2~2" || 2 | 2 | ONE 48 | "8~" || 8 | null | TO_END 49 | "109~" || 109 | null | TO_END 50 | "~2" || null | 2 | FROM_BEGINNING 51 | "~25" || null | 25 | FROM_BEGINNING 52 | "1~10" || 1 | 10 | SOME 53 | "29~30" || 29 | 30 | SOME 54 | } 55 | 56 | def "Gets the value"() { 57 | given: 58 | def episodeRange = new EpisodeRange() 59 | 60 | when: 61 | episodeRange.value = value 62 | def actual = episodeRange.value 63 | 64 | then: 65 | actual == expected 66 | 67 | where: 68 | value | expected 69 | "" | "*" 70 | "12" | value 71 | "2~2" | "2" 72 | "8~" | value 73 | "109~" | value 74 | "~2" | value 75 | "~25" | value 76 | "1~10" | value 77 | "29~30" | value 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/browser/ChromeOption.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.browser; 2 | 3 | import lombok.Getter; 4 | import lombok.RequiredArgsConstructor; 5 | 6 | import java.util.Arrays; 7 | import java.util.List; 8 | 9 | import static java.util.stream.Collectors.toList; 10 | 11 | /** 12 | * @since 2.6.2 13 | */ 14 | @Getter 15 | @RequiredArgsConstructor 16 | public enum ChromeOption { 17 | 18 | /** 19 | * Opens browser on private mode. 20 | */ 21 | INCOGNITO("--incognito"), 22 | 23 | /** 24 | * Runs browser using CLI. 25 | */ 26 | HEADLESS("--headless=new"), 27 | 28 | /** 29 | * Bypasses OS security model. 30 | * 31 | * @since 2.8.2 32 | */ 33 | NO_SANDBOX("--no-sandbox"), 34 | 35 | /** 36 | * Disables GPU computation (applicable to Windows OS only). 37 | * 38 | * @since 2.8.2 39 | */ 40 | DISABLE_GPU("--disable-gpu"), 41 | 42 | /** 43 | * Ignores certificate errors. 44 | */ 45 | IGNORE_CERTIFICATE_ERRORS("--ignore-certificate-errors"), 46 | 47 | /** 48 | * Disables to check if Google Chrome is default browser on your device. 49 | * 50 | * @since 2.8.2 51 | */ 52 | NO_DEFAULT_BROWSER_CHECK("--no-default-browser-check"), 53 | 54 | /** 55 | * Disables popup blocking. 56 | */ 57 | DISABLE_POPUP_BLOCKING("--disable-popup-blocking"), 58 | 59 | /** 60 | * Disables installed extensions(plugins) of Google Chrome. 61 | * 62 | * @since 2.8.2 63 | */ 64 | DISABLE_EXTENSIONS("--disable-extensions"), 65 | 66 | /** 67 | * Disables default web apps on Google Chrome’s new tab page 68 | *

69 | * Chrome Web Store, Google Drive, Gmail, YouTube, Google Search, etc. 70 | */ 71 | DISABLE_DEFAULT_APPS("--disable-default-apps"), 72 | 73 | /** 74 | * Disables Google translate feature. 75 | * 76 | * @since 2.8.2 77 | */ 78 | DISABLE_TRANSLATE("--disable-translate"), 79 | 80 | /** 81 | * Disables detection for client side phishing. 82 | * 83 | * @since 2.8.2 84 | */ 85 | DISABLE_CLIENT_SIDE_PHISHING_DETECTION("--disable-client-side-phishing-detection"), 86 | 87 | /** 88 | * Overcomes limited resource problems. 89 | * 90 | * @since 2.8.2 91 | */ 92 | DISABLE_DEV_SHM_USAGE("--disable-dev-shm-usage"), 93 | 94 | /** 95 | * Allows all remote origins. 96 | * 97 | * @since 3.0.4 98 | */ 99 | REMOTE_ALLOW_ALL_ORIGINS("--remote-allow-origins=*"), 100 | 101 | /** 102 | * Opens browser in maximized mode. 103 | */ 104 | START_MAXIMIZED("--start-maximized"); 105 | 106 | private final String argument; 107 | 108 | public static List getArguments() { 109 | return Arrays.stream(values()).map(it -> it.argument).collect(toList()); 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /github-imsejin-style-intellij-formatter.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/process/impl/AccessTokenProcessorSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.process.impl 2 | 3 | import io.github.imsejin.dl.lezhin.attribute.impl.AccessToken 4 | import io.github.imsejin.dl.lezhin.browser.WebBrowser 5 | import io.github.imsejin.dl.lezhin.exception.AccessTokenNotFoundException 6 | import io.github.imsejin.dl.lezhin.process.ProcessContext 7 | import org.mockito.MockedStatic 8 | import org.openqa.selenium.By 9 | import org.openqa.selenium.TimeoutException 10 | import org.openqa.selenium.WebElement 11 | import spock.lang.Specification 12 | import spock.lang.Subject 13 | 14 | import static org.mockito.Mockito.mockStatic 15 | import static org.mockito.Mockito.when 16 | 17 | @Subject(AccessTokenProcessor) 18 | class AccessTokenProcessorSpec extends Specification { 19 | 20 | private ProcessContext context 21 | 22 | private MockedStatic webBrowser 23 | 24 | void setup() { 25 | webBrowser = mockStatic(WebBrowser) 26 | context = ProcessContext.create() 27 | } 28 | 29 | void cleanup() { 30 | webBrowser.close() 31 | } 32 | 33 | // ------------------------------------------------------------------------------------------------- 34 | 35 | def "Succeeds"() { 36 | given: 37 | with(WebBrowser) { 38 | when(waitForPresenceOfElement(By.xpath("//script[not(@src) and contains(text(), '__LZ_ME__')]"))) 39 | .thenReturn(_ as WebElement) 40 | when(evaluate("window.__LZ_CONFIG__?.token", String)) 41 | .thenReturn("ab585aaf-3379-488a-a93e-8658145ff715") 42 | } 43 | 44 | when: 45 | def processor = new AccessTokenProcessor() 46 | def accessToken = processor.process(context) 47 | 48 | then: 49 | accessToken == new AccessToken("ab585aaf-3379-488a-a93e-8658145ff715") 50 | } 51 | 52 | def "Fails due to waiting for element"() { 53 | given: 54 | with(WebBrowser) { 55 | when(waitForPresenceOfElement(By.xpath("//script[not(@src) and contains(text(), '__LZ_ME__')]"))) 56 | .thenThrow(TimeoutException) 57 | } 58 | 59 | when: 60 | def processor = new AccessTokenProcessor() 61 | processor.process(context) 62 | 63 | then: 64 | def e = thrown(AccessTokenNotFoundException) 65 | e.message == "There is no access token" 66 | } 67 | 68 | def "Fails due to invalid token value"() { 69 | given: 70 | with(WebBrowser) { 71 | when(waitForPresenceOfElement(By.xpath("//script[not(@src) and contains(text(), '__LZ_ME__')]"))) 72 | .thenReturn(_ as WebElement) 73 | when(evaluate("window.__LZ_CONFIG__?.token", String)) 74 | .thenReturn("ab585aaf3379488aa93e8658145ff715") 75 | } 76 | 77 | when: 78 | def processor = new AccessTokenProcessor() 79 | processor.process(context) 80 | 81 | then: 82 | def e = thrown(AccessTokenNotFoundException) 83 | e.message == "Invalid access token: ab585aaf3379488aa93e8658145ff715" 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/process/impl/ConfigurationFileProcessorSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.process.impl 2 | 3 | import io.github.imsejin.dl.lezhin.exception.ConfigurationFileNotFoundException 4 | import io.github.imsejin.dl.lezhin.exception.InvalidConfigurationFileException 5 | import io.github.imsejin.dl.lezhin.process.ProcessContext 6 | import spock.lang.Specification 7 | import spock.lang.Subject 8 | import spock.lang.TempDir 9 | 10 | import java.nio.file.Files 11 | import java.nio.file.Path 12 | 13 | @Subject(ConfigurationFileProcessor) 14 | class ConfigurationFileProcessorSpec extends Specification { 15 | 16 | @TempDir 17 | private Path tempPath 18 | 19 | def "Failed to process due to no file"() { 20 | given: 21 | def processor = new ConfigurationFileProcessor(tempPath) 22 | 23 | when: 24 | processor.process(ProcessContext.create()) 25 | 26 | then: 27 | def e = thrown(ConfigurationFileNotFoundException) 28 | e.message == "There is no configuration file: ${tempPath.resolve("config.ini")}" 29 | } 30 | 31 | def "Failed to process due to no section[account]"() { 32 | given: 33 | def filePath = tempPath.resolve("config.ini") 34 | Files.createFile(filePath) 35 | 36 | when: 37 | def processor = new ConfigurationFileProcessor(tempPath) 38 | processor.process(ProcessContext.create()) 39 | 40 | then: 41 | def e = thrown(InvalidConfigurationFileException) 42 | e.message == "Configuration file has no section[account]: $filePath" 43 | } 44 | 45 | def "Failed to process due to no name[username]"() { 46 | given: 47 | def filePath = tempPath.resolve("config.ini") 48 | Files.writeString(filePath, """ 49 | [account] 50 | username= 51 | """) 52 | 53 | when: 54 | def processor = new ConfigurationFileProcessor(tempPath) 55 | processor.process(ProcessContext.create()) 56 | 57 | then: 58 | def e = thrown(InvalidConfigurationFileException) 59 | e.message == "It is invalid value of name[username] in section[account]: " 60 | } 61 | 62 | def "Failed to process due to no name[password]"() { 63 | given: 64 | def filePath = tempPath.resolve("config.ini") 65 | Files.writeString(filePath, """ 66 | [account] 67 | username=anonymous 68 | password= 69 | """) 70 | 71 | when: 72 | def processor = new ConfigurationFileProcessor(tempPath) 73 | processor.process(ProcessContext.create()) 74 | 75 | then: 76 | def e = thrown(InvalidConfigurationFileException) 77 | e.message == "It is invalid value of name[password] in section[account]: " 78 | } 79 | 80 | def "Processes configuration file"() { 81 | given: 82 | def filePath = tempPath.resolve("config.ini") 83 | Files.writeString(filePath, """ 84 | [account] 85 | username = anonymous 86 | password = encrypted 87 | """) 88 | 89 | when: 90 | def processor = new ConfigurationFileProcessor(tempPath) 91 | def authentication = processor.process(ProcessContext.create()) 92 | 93 | then: 94 | authentication.username == "anonymous" 95 | authentication.password == "encrypted" 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/common/PropertyBinderSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.common 2 | 3 | import io.github.imsejin.dl.lezhin.api.auth.service.AuthorityService.AuthData 4 | import io.github.imsejin.dl.lezhin.attribute.impl.Content 5 | import io.github.imsejin.dl.lezhin.attribute.impl.Content.Episode 6 | import spock.lang.Specification 7 | import spock.lang.Subject 8 | 9 | @Subject(PropertyBinder) 10 | class PropertyBinderSpec extends Specification { 11 | 12 | def "Converts into ServiceRequest"() { 13 | given: 14 | def content = new Content(id: 4733152235356160) 15 | def episode = new Episode(id: 5598233223495680) 16 | 17 | when: 18 | def serviceRequest = PropertyBinder.INSTANCE.toServiceRequest(content, episode, false) 19 | 20 | then: 21 | serviceRequest 22 | serviceRequest.contentId == 4733152235356160 23 | serviceRequest.episodeId == 5598233223495680 24 | !serviceRequest.purchased 25 | serviceRequest.q == 30 26 | serviceRequest.firstCheckType == 'P' 27 | } 28 | 29 | def "Converts into Authority"() { 30 | given: 31 | def authData = new AuthData( 32 | policy: "eyJTdGF0ZW1lbnQiOiBbeyJSZXNvdXJjZSI6Imh0dHBzOi8vcmNkbi5sZXpoaW4uY29tL3YyLyovMjc3L2VwaXNvZGVzLzYwNjYyMzc4MDk4ODUxODQvY29udGVudHMvKnB1cmNoYXNlZD1mYWxzZSpxPTMwKiIsIkNvbmRpdGlvbiI6eyJEYXRlTGVzc1RoYW4iOnsiQVdTOkVwb2NoVGltZSI6MTY3MDk0NTEwNn0sIklwQWRkcmVzcyI6eyJBV1M6U291cmNlSXAiOiIwLjAuMC4wLzAifX19XX0_", 33 | signature: "eZ8hK44g2yE9WLmb6trm19rdMl2DJj21~P8OA0DYwrmLa4F-Q8BZia0tUYZTYuwHJInmHOfC42njtXJrN1UBZTEDCJdS0qlsV-7UjJAqH-1Ma8JhVkkK4rcZZPkWsAYBgjEQy-ycOx79qxVeyx7m5oHqEPJnVm9xDh5U7ZvkFFc-WnRarqpSfD8aMhP-p-laIeSzmqmoDP9zAsTVdfxaN0e~x35vPsDSXgPWKr8SJQKaCqNxVswwbt1QNFf2YyRCFnM-vPWhIij86B8xL2vW7sImmmK6F98rZ24BnQVuyTU9C1IHcjlMOczVdQwofuds94sdLcpl9CYRnczZhUClLg__", 34 | keyPairId: "B192C1A8G7FT0Z", 35 | ) 36 | 37 | when: 38 | def authority = PropertyBinder.INSTANCE.toAuthority(authData) 39 | 40 | then: 41 | authority 42 | authority.policy == authData.policy 43 | authority.signature == authData.signature 44 | authority.keyPairId == authData.keyPairId 45 | authority.contentId == 277 46 | authority.episodeId == 6066237809885184 47 | authority.expiredAt == 1670945106 48 | authority.expired 49 | } 50 | 51 | def "Converts into HttpHosts"() { 52 | given: 53 | def config = [ 54 | apiUrl : "api.lezhin.com", 55 | cdnUrl : "https://ccdn.lezhin.com", 56 | contentsCdnUrl: "https://rcdn.lezhin.com", 57 | recoUrl : "dondog.lezhin.com", 58 | payUrl : "https://pay.lezhin.com", 59 | pantherUrl : "https://panther.lezhin.com", 60 | ] 61 | 62 | when: 63 | def httpHosts = PropertyBinder.INSTANCE.toHttpHosts(config) 64 | 65 | then: 66 | httpHosts 67 | httpHosts.api == "https://api.lezhin.com/" 68 | httpHosts.cdn == "https://ccdn.lezhin.com/" 69 | httpHosts.contentsCdn == "https://rcdn.lezhin.com/" 70 | httpHosts.reco == "https://dondog.lezhin.com/" 71 | httpHosts.pay == "https://pay.lezhin.com/" 72 | httpHosts.panther == "https://panther.lezhin.com/" 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/attribute/impl/Content.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.attribute.impl; 2 | 3 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.Setter; 7 | import lombok.ToString; 8 | import org.jetbrains.annotations.Nullable; 9 | 10 | import java.util.List; 11 | 12 | /** 13 | * @since 3.0.0 14 | */ 15 | @Getter 16 | @Setter 17 | @ToString 18 | @EqualsAndHashCode 19 | public final class Content implements Attribute { 20 | 21 | private Long id; 22 | 23 | /** 24 | * Content name in URI. 25 | */ 26 | private String alias; 27 | 28 | private Display display; 29 | 30 | private Properties properties; 31 | 32 | private String locale; 33 | 34 | private String state; 35 | 36 | private List artists; 37 | 38 | private List episodes; 39 | 40 | // ------------------------------------------------------------------------------------------------- 41 | 42 | /** 43 | * @since 1.0.0 44 | */ 45 | @Getter 46 | @Setter 47 | @ToString 48 | @EqualsAndHashCode 49 | public static final class Artist { 50 | private String id; 51 | private String name; 52 | private String role; 53 | private String email; 54 | } 55 | 56 | // ------------------------------------------------------------------------------------------------- 57 | 58 | /** 59 | * @since 1.0.0 60 | */ 61 | @Getter 62 | @Setter 63 | @ToString 64 | @EqualsAndHashCode 65 | public static final class Episode { 66 | private Long id; 67 | 68 | /** 69 | * Episode name in URI. 70 | */ 71 | private String name; 72 | 73 | private Integer seq; 74 | 75 | private Display display; 76 | 77 | private Properties properties; 78 | 79 | private Integer coin; 80 | 81 | /** 82 | * When episode was uploaded to the Lezhin Comics server. 83 | */ 84 | private Long updatedAt; 85 | 86 | /** 87 | * When episode actually appears on a web page to users. 88 | */ 89 | private Long publishedAt; 90 | 91 | /** 92 | * When to change to a free episode. 93 | * 94 | *

If this is {@code null}, it means the episode doesn't turn free even if you wait. 95 | */ 96 | @Nullable 97 | private Long freedAt; 98 | 99 | /** 100 | * @since 2.6.0 101 | */ 102 | public boolean isFree() { 103 | return this.coin == 0 || (this.freedAt != null && this.freedAt <= System.currentTimeMillis()); 104 | } 105 | } 106 | 107 | // ------------------------------------------------------------------------------------------------- 108 | 109 | /** 110 | * @since 1.0.0 111 | */ 112 | @Getter 113 | @Setter 114 | @ToString 115 | @EqualsAndHashCode 116 | public static final class Display { 117 | private String title; 118 | private String synopsis; 119 | } 120 | 121 | // ------------------------------------------------------------------------------------------------- 122 | 123 | /** 124 | * @since 1.0.0 125 | */ 126 | @Getter 127 | @Setter 128 | @ToString 129 | @EqualsAndHashCode 130 | public static final class Properties { 131 | private boolean expired; 132 | private boolean notForSale; 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/http/url/URIs.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.http.url; 18 | 19 | import io.github.imsejin.common.assertion.Asserts; 20 | import io.github.imsejin.common.util.ArrayUtils; 21 | import lombok.RequiredArgsConstructor; 22 | 23 | import java.util.regex.Matcher; 24 | import java.util.regex.Pattern; 25 | 26 | /** 27 | * @since 2.5.0 28 | */ 29 | @RequiredArgsConstructor 30 | public enum URIs { 31 | 32 | /** 33 | * Login page. 34 | * 35 | *

{@code
 36 |      *     https://www.lezhin.com/ko/login
 37 |      * }
38 | */ 39 | LOGIN("/{language}/login"), 40 | 41 | /** 42 | * Page to choose your locale. 43 | * 44 | *
{@code
 45 |      *     /ko/locale/ko-KR
 46 |      * }
47 | */ 48 | LOCALE("/{language}/locale/{locale}"), 49 | 50 | EXPIRATION("/{language}/error/expired"), 51 | 52 | /** 53 | * Comic page that shows its episodes. 54 | * 55 | *
{@code
 56 |      *     /ko/comic/redhood
 57 |      * }
58 | */ 59 | CONTENT("/{language}/comic/{contentName}"), 60 | 61 | /** 62 | * Episode page that shows its cuts(images). 63 | * 64 | *
{@code
 65 |      *     /ko/comic/redhood/9
 66 |      *     /ko/comic/redhood/e1
 67 |      * }
68 | */ 69 | EPISODE("/{language}/comic/{comicName}/{episodeName}"), 70 | 71 | LIBRARY_CONTENT("/{language}/library/comic/{locale}/{contentName}"), 72 | 73 | LIBRARY_EPISODE("/{language}/library/comic/{locale}/{contentName}/{episodeName}"), 74 | 75 | EPISODE_IMAGE("/v2/comics/{contentId}/episodes/{episodeId}/contents/scrolls/{num}.{imageFormat}" + 76 | "?purchased={purchased}&q=30&Policy={policy}&Signature={signature}&Key-Pair-Id={keyPairId}"); 77 | 78 | private static final Pattern PATTERN = Pattern.compile("\\{(.+?)}", Pattern.MULTILINE); 79 | 80 | private final String template; 81 | 82 | /** 83 | * Returns a URI string. 84 | * 85 | * @param params parameters 86 | * @return URI string 87 | */ 88 | public String get(Object... params) { 89 | if (ArrayUtils.isNullOrEmpty(params)) { 90 | return this.template; 91 | } 92 | 93 | Matcher matcher = PATTERN.matcher(this.template); 94 | 95 | // Converts all variables to parameters. 96 | String uri = this.template; 97 | for (int i = 0; i < params.length && matcher.find(); i++) { 98 | String param = String.valueOf(params[i]); 99 | uri = uri.replaceAll("\\{" + matcher.group(1) + '}', param); 100 | } 101 | 102 | // Validates all variables in URI are converted to parameters. 103 | Asserts.that(PATTERN.matcher(uri).find()) 104 | .describedAs("Template URI has not matched variable(s): '{0}'", uri) 105 | .isFalse(); 106 | 107 | return uri; 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/process/impl/HttpHostsProcessor.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.process.impl; 18 | 19 | import io.github.imsejin.common.util.CollectionUtils; 20 | import io.github.imsejin.dl.lezhin.annotation.ProcessSpecification; 21 | import io.github.imsejin.dl.lezhin.attribute.impl.HttpHosts; 22 | import io.github.imsejin.dl.lezhin.browser.WebBrowser; 23 | import io.github.imsejin.dl.lezhin.common.Loggers; 24 | import io.github.imsejin.dl.lezhin.common.PropertyBinder; 25 | import io.github.imsejin.dl.lezhin.exception.URLConfigurationNotFoundException; 26 | import io.github.imsejin.dl.lezhin.process.ProcessContext; 27 | import io.github.imsejin.dl.lezhin.process.Processor; 28 | import org.openqa.selenium.By; 29 | import org.openqa.selenium.NoSuchElementException; 30 | import org.openqa.selenium.TimeoutException; 31 | import org.openqa.selenium.chrome.ChromeDriver; 32 | 33 | import java.util.Map; 34 | 35 | /** 36 | * Processor for extraction of HTTP URL configuration 37 | * 38 | *

If you enter any pages of lezhin comics, they provide the configuration of HTTP URL for their APIs 39 | * into script tag. So {@link ChromeDriver} extracts the token from the script tag of which {@code innerText}. 40 | * 41 | *

The following code is {@code innerText} in the script tag. 42 | * 43 | *

{@code
44 |  *     
55 |  * }
56 | * 57 | * @since 3.0.0 58 | */ 59 | @ProcessSpecification(dependsOn = LoginProcessor.class) 60 | public class HttpHostsProcessor implements Processor { 61 | 62 | /** 63 | * Performs a process of HTTP URL configuration. 64 | * 65 | * @param context process context 66 | * @return HTTP URL configuration 67 | * @throws URLConfigurationNotFoundException if the configuration is not found 68 | */ 69 | @Override 70 | public HttpHosts process(ProcessContext context) throws URLConfigurationNotFoundException { 71 | try { 72 | // Finds a script tag that has the configuration. 73 | WebBrowser.waitForPresenceOfElement(By.xpath("//script[not(@src) and contains(text(), '__LZ_CONFIG__')]")); 74 | } catch (NoSuchElementException | TimeoutException e) { 75 | throw new URLConfigurationNotFoundException(e, "There is no configuration of URL"); 76 | } 77 | 78 | @SuppressWarnings("unchecked") 79 | Map config = WebBrowser.evaluate("window.__LZ_CONFIG__", Map.class); 80 | 81 | if (CollectionUtils.isNullOrEmpty(config)) { 82 | throw new URLConfigurationNotFoundException("Invalid configuration of URL: %s", config); 83 | } 84 | 85 | HttpHosts httpHosts = PropertyBinder.INSTANCE.toHttpHosts(config); 86 | Loggers.getLogger().debug("Found the configuration of URL: {}", httpHosts); 87 | 88 | return httpHosts; 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/api/purchase/service/PurchasedEpisodeService.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.api.purchase.service; 18 | 19 | import com.google.gson.JsonArray; 20 | import com.google.gson.JsonElement; 21 | import com.google.gson.JsonParser; 22 | import io.github.imsejin.common.util.StreamUtils; 23 | import io.github.imsejin.common.util.StringUtils; 24 | import io.github.imsejin.dl.lezhin.api.BaseService; 25 | import io.github.imsejin.dl.lezhin.argument.impl.Language; 26 | import io.github.imsejin.dl.lezhin.common.Loggers; 27 | import retrofit2.Call; 28 | import retrofit2.Response; 29 | import retrofit2.Retrofit; 30 | import retrofit2.converter.scalars.ScalarsConverterFactory; 31 | import retrofit2.http.GET; 32 | import retrofit2.http.Path; 33 | import retrofit2.http.Query; 34 | 35 | import java.io.IOException; 36 | import java.util.List; 37 | import java.util.UUID; 38 | 39 | import static java.util.stream.Collectors.toUnmodifiableList; 40 | 41 | /** 42 | * @since 3.0.0 43 | */ 44 | public class PurchasedEpisodeService extends BaseService { 45 | 46 | private final ServiceInterface serviceInterface; 47 | 48 | private final Language language; 49 | 50 | public PurchasedEpisodeService(Language language, UUID accessToken) { 51 | super(language.getValue(), accessToken); 52 | 53 | Retrofit retrofit = new Retrofit.Builder() 54 | .baseUrl("https://www.lezhin.com/lz-api/v2/") 55 | .addConverterFactory(ScalarsConverterFactory.create()) 56 | .client(BaseService.getHttpClient()) 57 | .build(); 58 | 59 | this.serviceInterface = retrofit.create(ServiceInterface.class); 60 | this.language = language; 61 | } 62 | 63 | public List getPurchasedEpisodeIdList(String contentAlias) { 64 | Loggers.getLogger().debug("Request: https://www.lezhin.com/lz-api/v2/contents/{}/users", contentAlias); 65 | Call call = this.serviceInterface.getPurchasedEpisodes(contentAlias, "comic", 66 | this.language.getValue().getLanguage(), this.language.asLocaleString()); 67 | 68 | Response response; 69 | try { 70 | response = call.execute(); 71 | } catch (IOException e) { 72 | throw new RuntimeException(e.getMessage(), e); 73 | } 74 | 75 | String json = response.body(); 76 | if (StringUtils.isNullOrBlank(json)) { 77 | throw new IllegalArgumentException("Failed to get list of purchased episode: " + contentAlias); 78 | } 79 | 80 | JsonElement jsonElement = JsonParser.parseString(json); 81 | JsonElement data = jsonElement.getAsJsonObject().get("data"); 82 | JsonArray jsonArray = data.getAsJsonObject().get("purchased").getAsJsonArray(); 83 | 84 | return StreamUtils.toStream(jsonArray.iterator()).map(JsonElement::getAsLong).collect(toUnmodifiableList()); 85 | } 86 | 87 | // ------------------------------------------------------------------------------------------------- 88 | 89 | private interface ServiceInterface { 90 | @GET("contents/{contentAlias}/users") 91 | Call getPurchasedEpisodes( 92 | @Path("contentAlias") String contentAlias, 93 | @Query("type") String type, 94 | @Query("country_code") String countryCode, 95 | @Query("locale") String locale); 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/exception/LoginException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.exception; 18 | 19 | /** 20 | * @since 2.7.0 21 | */ 22 | public class LoginException extends LezhinComicsDownloaderException { 23 | 24 | public LoginException(String errorCode) { 25 | super("Failed to login: %s", convertErrorCode(errorCode)); 26 | } 27 | 28 | public LoginException(Throwable cause, String format, Object... args) { 29 | super(cause, format, args); 30 | } 31 | 32 | /** 33 | * Converts error code to error message. 34 | * 35 | *

The following script is in login page. First, {@code __LZ_ERROR_CODE__} is {@code ''}. 36 | * If you failed to login, it is assigned to error code and {@code __LZ_ERROR__} is assigned to error message. 37 | * 38 | *

{@code
39 |      *     
78 |      * }
79 | * 80 | * @param errorCode error code 81 | * @return error message 82 | */ 83 | private static String convertErrorCode(String errorCode) { 84 | switch (errorCode) { 85 | case "1101": 86 | return "non-existent username"; 87 | case "1104": 88 | return "invalid password"; 89 | default: 90 | return "unrecognized error"; 91 | } 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/Application.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin; 18 | 19 | import io.github.imsejin.common.util.ClassUtils; 20 | import io.github.imsejin.dl.lezhin.argument.Argument; 21 | import io.github.imsejin.dl.lezhin.argument.ArgumentsParser; 22 | import io.github.imsejin.dl.lezhin.argument.impl.ContentName; 23 | import io.github.imsejin.dl.lezhin.argument.impl.DebugMode; 24 | import io.github.imsejin.dl.lezhin.argument.impl.EpisodeRange; 25 | import io.github.imsejin.dl.lezhin.argument.impl.ImageFormat; 26 | import io.github.imsejin.dl.lezhin.argument.impl.Language; 27 | import io.github.imsejin.dl.lezhin.argument.impl.SingleThreading; 28 | import io.github.imsejin.dl.lezhin.browser.WebBrowser; 29 | import io.github.imsejin.dl.lezhin.common.Loggers; 30 | import io.github.imsejin.dl.lezhin.exception.LezhinComicsDownloaderException; 31 | import io.github.imsejin.dl.lezhin.process.ProcessContext; 32 | import io.github.imsejin.dl.lezhin.process.Processor; 33 | import io.github.imsejin.dl.lezhin.process.framework.ProcessorCreator; 34 | import io.github.imsejin.dl.lezhin.process.framework.ProcessorOrderResolver; 35 | import io.github.imsejin.dl.lezhin.util.PathUtils; 36 | import org.reflections.Reflections; 37 | 38 | import java.util.List; 39 | import java.util.Set; 40 | 41 | import static java.util.stream.Collectors.toUnmodifiableSet; 42 | 43 | public final class Application { 44 | 45 | public static void main(String[] args) { 46 | try { 47 | ArgumentsParser argumentsParser = new ArgumentsParser( 48 | new Language(), new ContentName(), new EpisodeRange(), 49 | new ImageFormat(), new SingleThreading(), new DebugMode()); 50 | List arguments = argumentsParser.parse(args); 51 | 52 | ProcessContext context = ProcessContext.create(arguments.toArray()); 53 | if (context.getDebugMode().getValue()) { 54 | Loggers.debugging(); 55 | WebBrowser.debugging(); 56 | } 57 | 58 | List processors = createProcessors(); 59 | 60 | for (Processor processor : processors) { 61 | Object attribute = processor.process(context); 62 | context.add(attribute); 63 | } 64 | } catch (Throwable t) { 65 | Loggers.getLogger().error("Failed to perform a process", t); 66 | } finally { 67 | WebBrowser.quitIfInitialized(); 68 | } 69 | } 70 | 71 | // ------------------------------------------------------------------------------------------------- 72 | 73 | private static List createProcessors() throws LezhinComicsDownloaderException { 74 | // Finds all types of implementation of the processor. 75 | Set> processorTypes = new Reflections(Application.class) 76 | .getSubTypesOf(Processor.class).stream() 77 | .filter(it -> it.getEnclosingClass() == null && !ClassUtils.isAbstractClass(it)) 78 | .collect(toUnmodifiableSet()); 79 | 80 | // Sorts order of the types. 81 | List> orderedTypes = ProcessorOrderResolver.resolve(processorTypes); 82 | 83 | // Prepares objects needed to instantiate the processors. 84 | List beans = List.of(PathUtils.getCurrentPath()); 85 | 86 | // Creates the processors with beans. 87 | ProcessorCreator processorCreator = new ProcessorCreator(beans.toArray()); 88 | return processorCreator.create(orderedTypes); 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/api/auth/model/Authority.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.api.auth.model; 18 | 19 | import com.google.gson.JsonArray; 20 | import com.google.gson.JsonObject; 21 | import com.google.gson.JsonParser; 22 | import io.github.imsejin.common.util.StringUtils; 23 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 24 | import lombok.EqualsAndHashCode; 25 | import lombok.Getter; 26 | import lombok.ToString; 27 | 28 | import java.nio.charset.StandardCharsets; 29 | import java.util.Base64; 30 | import java.util.regex.Pattern; 31 | 32 | /** 33 | * @since 3.0.0 34 | */ 35 | @Getter 36 | @ToString 37 | @EqualsAndHashCode 38 | public class Authority implements Attribute { 39 | 40 | private static final Pattern RESOURCE_PATTERN = Pattern.compile( 41 | "^https://\\w+\\.lezhin\\.com/v2/.+/([0-9]+)/episodes/([0-9]+)/contents"); 42 | 43 | private final String policy; 44 | 45 | private final String signature; 46 | 47 | private final String keyPairId; 48 | 49 | private final Long contentId; 50 | 51 | private final Long episodeId; 52 | 53 | private final Long expiredAt; 54 | 55 | public Authority(String policy, String signature, String keyPairId) { 56 | this.policy = policy; 57 | this.signature = signature; 58 | this.keyPairId = keyPairId; 59 | 60 | // URL and Filename safe Base64 (RFC 4648) 61 | byte[] bytes = policy.replaceAll("[-_]", "").getBytes(StandardCharsets.UTF_8); 62 | String decoded = new String(Base64.getUrlDecoder().decode(bytes), StandardCharsets.UTF_8); 63 | JsonObject jsonObject = JsonParser.parseString(decoded).getAsJsonObject(); 64 | 65 | this.contentId = extractContentId(jsonObject); 66 | this.episodeId = extractEpisodeId(jsonObject); 67 | this.expiredAt = extractExpiredAt(jsonObject); 68 | } 69 | 70 | public boolean isExpired() { 71 | long currentTimeSeconds = System.currentTimeMillis() / 1000; 72 | return this.expiredAt < currentTimeSeconds; 73 | } 74 | 75 | // ------------------------------------------------------------------------------------------------- 76 | 77 | private static Long extractContentId(JsonObject jsonObject) { 78 | JsonArray statement = jsonObject.get("Statement").getAsJsonArray(); 79 | JsonObject element = statement.get(0).getAsJsonObject(); 80 | 81 | String resourceUrl = element.get("Resource").getAsString(); 82 | String contentId = StringUtils.find(resourceUrl, RESOURCE_PATTERN, 1); 83 | 84 | return Long.parseLong(contentId); 85 | } 86 | 87 | private static Long extractEpisodeId(JsonObject jsonObject) { 88 | JsonArray statement = jsonObject.get("Statement").getAsJsonArray(); 89 | JsonObject element = statement.get(0).getAsJsonObject(); 90 | 91 | String resourceUrl = element.get("Resource").getAsString(); 92 | String episodeId = StringUtils.find(resourceUrl, RESOURCE_PATTERN, 2); 93 | 94 | return Long.parseLong(episodeId); 95 | } 96 | 97 | private static Long extractExpiredAt(JsonObject jsonObject) { 98 | JsonArray statement = jsonObject.get("Statement").getAsJsonArray(); 99 | JsonObject element = statement.get(0).getAsJsonObject(); 100 | 101 | JsonObject condition = element.get("Condition").getAsJsonObject(); 102 | JsonObject dateLessThan = condition.get("DateLessThan").getAsJsonObject(); 103 | 104 | return dateLessThan.get("AWS:EpochTime").getAsLong(); 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/process/impl/AccessTokenProcessor.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.process.impl; 18 | 19 | import io.github.imsejin.dl.lezhin.annotation.ProcessSpecification; 20 | import io.github.imsejin.dl.lezhin.attribute.impl.AccessToken; 21 | import io.github.imsejin.dl.lezhin.browser.WebBrowser; 22 | import io.github.imsejin.dl.lezhin.common.Loggers; 23 | import io.github.imsejin.dl.lezhin.exception.AccessTokenNotFoundException; 24 | import io.github.imsejin.dl.lezhin.process.ProcessContext; 25 | import io.github.imsejin.dl.lezhin.process.Processor; 26 | import org.openqa.selenium.By; 27 | import org.openqa.selenium.NoSuchElementException; 28 | import org.openqa.selenium.TimeoutException; 29 | import org.openqa.selenium.chrome.ChromeDriver; 30 | 31 | import java.util.regex.Pattern; 32 | 33 | /** 34 | * Processor for extraction of access token 35 | * 36 | *

If you success to login, lezhin generates user's configuration that has an access token 37 | * into script tag. So {@link ChromeDriver} extracts the token from the script tag of which {@code innerText}. 38 | * 39 | *

The following code is {@code innerText} in the script tag. 40 | * 41 | *

{@code
 42 |  *     
 68 |  * }
69 | * 70 | * @since 3.0.0 71 | */ 72 | @ProcessSpecification(dependsOn = HttpHostsProcessor.class) 73 | public class AccessTokenProcessor implements Processor { 74 | 75 | /** 76 | * Performs a process of access token. 77 | * 78 | * @param context process context 79 | * @return access token 80 | * @throws AccessTokenNotFoundException if access token is not found 81 | */ 82 | @Override 83 | public AccessToken process(ProcessContext context) throws AccessTokenNotFoundException { 84 | try { 85 | // Finds a script tag that has access token. 86 | WebBrowser.waitForPresenceOfElement(By.xpath("//script[not(@src) and contains(text(), '__LZ_ME__')]")); 87 | } catch (NoSuchElementException | TimeoutException e) { 88 | throw new AccessTokenNotFoundException(e, "There is no access token"); 89 | } 90 | 91 | String token = WebBrowser.evaluate("window.__LZ_CONFIG__?.token", String.class); 92 | 93 | Pattern pattern = Pattern.compile("[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}"); 94 | if (token == null || !pattern.matcher(token).matches()) { 95 | throw new AccessTokenNotFoundException("Invalid access token: %s", token); 96 | } 97 | 98 | AccessToken accessToken = new AccessToken(token); 99 | Loggers.getLogger().info("Successfully logged in: access token({})", token); 100 | 101 | return accessToken; 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/util/FileNameUtils.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.util; 18 | 19 | import io.github.imsejin.common.annotation.ExcludeFromGeneratedJacocoReport; 20 | import io.github.imsejin.common.util.FilenameUtils; 21 | import io.github.imsejin.common.util.StringUtils; 22 | 23 | import java.util.Map; 24 | import java.util.regex.Pattern; 25 | 26 | /** 27 | * Utility for name of file. 28 | * 29 | * @see 30 | * Naming Files, Paths, and Namespaces on Windows 31 | */ 32 | public final class FileNameUtils { 33 | 34 | private static final Map REPLACEMENT_MAP = Map.ofEntries( 35 | Map.entry('<', '<'), 36 | Map.entry('>', '>'), 37 | Map.entry(':', ':'), 38 | Map.entry('"', '"'), 39 | Map.entry('/', '/'), 40 | Map.entry('\\', '\'), 41 | Map.entry('|', '|'), 42 | Map.entry('?', '?'), 43 | Map.entry('*', '*') 44 | ); 45 | 46 | private static final Pattern WINDOWS_RESERVED_BASE_NAME_PATTERN = Pattern.compile( 47 | "CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9]", Pattern.CASE_INSENSITIVE); 48 | 49 | @ExcludeFromGeneratedJacocoReport 50 | private FileNameUtils() { 51 | throw new UnsupportedOperationException(getClass().getName() + " is not allowed to instantiate"); 52 | } 53 | 54 | public static String replaceForbiddenCharacters(String fileName) { 55 | StringBuilder sb = new StringBuilder(); 56 | int length = fileName.length(); 57 | 58 | for (int i = 0; i < length; i++) { 59 | char c = fileName.charAt(i); 60 | 61 | // Printable ASCII characters 62 | Character replacement = REPLACEMENT_MAP.get(c); 63 | if (replacement != null) { 64 | sb.append(replacement); 65 | continue; 66 | } 67 | 68 | sb.append(c); 69 | } 70 | 71 | return sb.toString() 72 | .replaceAll("\\.{2,}+$", "…") 73 | .replaceAll("\\.$", "."); 74 | } 75 | 76 | public static String sanitize(String fileName) { 77 | StringBuilder baseNameBuilder = new StringBuilder(); 78 | 79 | // Sanitizes base name first. 80 | String baseName = FilenameUtils.getBaseName(fileName).strip(); 81 | 82 | for (int i = 0; i < baseName.length(); i++) { 83 | char c = baseName.charAt(i); 84 | 85 | // Non-printable characters: ASCII control characters (0-31) 86 | if (c < 32) { 87 | continue; 88 | } 89 | 90 | baseNameBuilder.append(c); 91 | } 92 | 93 | // Reserved base names on Windows 94 | if (WINDOWS_RESERVED_BASE_NAME_PATTERN.matcher(baseNameBuilder).matches()) { 95 | // Inserts a prefix into the base name. 96 | baseNameBuilder.insert(0, '_'); 97 | } 98 | 99 | String extension = FilenameUtils.getExtension(fileName).strip(); 100 | 101 | // When file name doesn't have extension. 102 | if (StringUtils.isNullOrEmpty(extension)) { 103 | return baseNameBuilder.toString(); 104 | } 105 | 106 | // Sanitizes file extension. 107 | StringBuilder extensionBuilder = new StringBuilder(); 108 | 109 | for (int i = 0; i < extension.length(); i++) { 110 | char c = extension.charAt(i); 111 | 112 | // Non-printable characters: ASCII control characters (0-31) 113 | if (c < 32) { 114 | continue; 115 | } 116 | 117 | extensionBuilder.append(c); 118 | } 119 | 120 | return baseNameBuilder.append('.').append(extensionBuilder).toString(); 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/process/impl/ContentInformationProcessor.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.process.impl; 18 | 19 | import io.github.imsejin.common.assertion.Asserts; 20 | import io.github.imsejin.common.util.JsonUtils; 21 | import io.github.imsejin.dl.lezhin.annotation.ProcessSpecification; 22 | import io.github.imsejin.dl.lezhin.argument.impl.Language; 23 | import io.github.imsejin.dl.lezhin.attribute.impl.Content; 24 | import io.github.imsejin.dl.lezhin.browser.WebBrowser; 25 | import io.github.imsejin.dl.lezhin.common.Loggers; 26 | import io.github.imsejin.dl.lezhin.exception.LezhinComicsDownloaderException; 27 | import io.github.imsejin.dl.lezhin.http.url.URIs; 28 | import io.github.imsejin.dl.lezhin.process.ProcessContext; 29 | import io.github.imsejin.dl.lezhin.process.Processor; 30 | import org.openqa.selenium.By; 31 | 32 | import java.util.Locale; 33 | 34 | /** 35 | * Processor for figuring out information of the content 36 | * 37 | * @since 3.0.0 38 | */ 39 | @ProcessSpecification(dependsOn = LocaleSelectionProcessor.class) 40 | public class ContentInformationProcessor implements Processor { 41 | 42 | @Override 43 | public Content process(ProcessContext context) throws LezhinComicsDownloaderException { 44 | Locale locale = context.getLanguage().getValue(); 45 | 46 | // Goes to page of the content. 47 | String contentPath = URIs.CONTENT.get(locale.getLanguage(), context.getContentName().getValue()); 48 | Loggers.getLogger().info("Request comic page: {}", contentPath); 49 | WebBrowser.request(contentPath); 50 | 51 | String jsonString; 52 | 53 | // Checks expiration of the content. 54 | String currentUrl = WebBrowser.getCurrentUrl(); 55 | String expirationPath = URIs.EXPIRATION.get(locale.getLanguage()); 56 | boolean expired = currentUrl.endsWith(expirationPath); 57 | if (expired) { 58 | Loggers.getLogger().info("Comic is expired -> try to find it in 'My Library'"); 59 | jsonString = getJsonInMyLibrary(context); 60 | } else { 61 | // Waits for DOM to complete the rendering. 62 | Loggers.getLogger().debug("Wait up to {} sec for episode list to be rendered", WebBrowser.DEFAULT_TIMEOUT_SECONDS); 63 | WebBrowser.waitForVisibilityOfElement(By.xpath( 64 | "//main[@id='main' and @class='lzCntnr lzCntnr--episode']")); 65 | jsonString = WebBrowser.evaluate("JSON.stringify(window.__LZ_PRODUCT__.product)", String.class); 66 | } 67 | 68 | Content content = JsonUtils.toObject(jsonString, Content.class); 69 | Asserts.that(content) 70 | .isNotNull() 71 | .describedAs("Content.properties.expired is expected to be '{0}'", expired) 72 | .returns(expired, it -> it.getProperties().isExpired()); 73 | 74 | return content; 75 | } 76 | 77 | // ------------------------------------------------------------------------------------------------- 78 | 79 | /** 80 | * @since 2.6.0 81 | */ 82 | private static String getJsonInMyLibrary(ProcessContext context) { 83 | Language language = context.getLanguage(); 84 | 85 | String libraryContentPath = URIs.LIBRARY_CONTENT.get( 86 | language.getValue().getLanguage(), language.asLocaleString(), context.getContentName().getValue()); 87 | Loggers.getLogger().debug("Request comic page in 'My Library': {}", libraryContentPath); 88 | WebBrowser.request(libraryContentPath); 89 | 90 | // Waits for DOM to complete the rendering. 91 | Loggers.getLogger().debug("Wait up to {} sec for episode list to be rendered", WebBrowser.DEFAULT_TIMEOUT_SECONDS); 92 | WebBrowser.waitForVisibilityOfElement(By.xpath( 93 | "//ul[@id='library-episode-list' and @class='epsList']")); 94 | 95 | return WebBrowser.evaluate("JSON.stringify(window.__LZ_PRODUCT__.product)", String.class); 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/process/framework/ProcessorCreator.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.process.framework; 2 | 3 | import io.github.imsejin.common.util.CollectionUtils; 4 | import io.github.imsejin.common.util.ReflectionUtils; 5 | import io.github.imsejin.dl.lezhin.exception.ProcessorCreationException; 6 | import io.github.imsejin.dl.lezhin.process.Processor; 7 | 8 | import java.lang.reflect.Constructor; 9 | import java.util.ArrayList; 10 | import java.util.Arrays; 11 | import java.util.Collections; 12 | import java.util.HashSet; 13 | import java.util.List; 14 | import java.util.Map; 15 | import java.util.Set; 16 | 17 | import static java.util.Comparator.comparing; 18 | import static java.util.stream.Collectors.toList; 19 | import static java.util.stream.Collectors.toMap; 20 | 21 | public final class ProcessorCreator { 22 | 23 | private final Set beans = new HashSet<>(); 24 | 25 | public ProcessorCreator(Object... beans) { 26 | for (Object bean : beans) { 27 | if (bean != null) { 28 | this.beans.add(bean); 29 | } 30 | } 31 | } 32 | 33 | public List create(List> processorTypes) throws ProcessorCreationException { 34 | if (CollectionUtils.isNullOrEmpty(processorTypes)) { 35 | return Collections.emptyList(); 36 | } 37 | 38 | List processors = new ArrayList<>(); 39 | 40 | for (Class processorType : processorTypes) { 41 | Constructor constructor = resolveConstructor(processorType); 42 | Processor processor = createProcessor(constructor); 43 | 44 | processors.add(processor); 45 | } 46 | 47 | return Collections.unmodifiableList(processors); 48 | } 49 | 50 | // ------------------------------------------------------------------------------------------------- 51 | 52 | private Constructor resolveConstructor(Class processorType) throws ProcessorCreationException { 53 | // Resolves a constructor that has fewer number of parameters first. 54 | List> constructors = Arrays.stream(processorType.getDeclaredConstructors()) 55 | .sorted(comparing(Constructor::getParameterCount)).collect(toList()); 56 | 57 | constructor_scope: 58 | for (Constructor constructor : constructors) { 59 | // Resolves a default constructor. 60 | if (constructor.getParameterCount() == 0) { 61 | return constructor; 62 | } 63 | 64 | param_scope: 65 | for (Class paramType : constructor.getParameterTypes()) { 66 | for (Object bean : this.beans) { 67 | if (paramType.isAssignableFrom(bean.getClass())) { 68 | continue param_scope; 69 | } 70 | } 71 | 72 | // Failed to resolve this constructor and tries to resolve the next constructor. 73 | continue constructor_scope; 74 | } 75 | 76 | // The parameters of this constructor are assignable from all beans. 77 | return constructor; 78 | } 79 | 80 | throw new ProcessorCreationException("There is no matched bean for parameter of the processor: (processorType=%s, beans=%s)", 81 | processorType, this.beans.stream().map(Object::getClass).collect(toList())); 82 | } 83 | 84 | private Processor createProcessor(Constructor constructor) { 85 | if (constructor.getParameterCount() == 0) { 86 | return (Processor) ReflectionUtils.instantiate(constructor); 87 | } 88 | 89 | Class[] paramTypes = constructor.getParameterTypes(); 90 | Object[] arguments = new Object[paramTypes.length]; 91 | 92 | Map, Object> beanTypeMap = this.beans.stream().collect(toMap(Object::getClass, it -> it)); 93 | 94 | for (int i = 0; i < paramTypes.length; i++) { 95 | Class paramType = paramTypes[i]; 96 | 97 | // Picks a bean strictly. 98 | Object bean = beanTypeMap.get(paramType); 99 | 100 | // If no matched bean, picks a bean leniently. 101 | if (bean == null) { 102 | bean = this.beans.stream().filter(it -> paramType.isAssignableFrom(it.getClass())) 103 | .findFirst().orElseThrow(); 104 | } 105 | 106 | arguments[i] = bean; 107 | } 108 | 109 | return (Processor) ReflectionUtils.instantiate(constructor, arguments); 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/argument/ArgumentsParser.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.argument; 18 | 19 | import io.github.imsejin.common.util.ArrayUtils; 20 | import io.github.imsejin.common.util.ReflectionUtils; 21 | import io.github.imsejin.dl.lezhin.exception.DuplicatedArgumentException; 22 | import io.github.imsejin.dl.lezhin.exception.ParsingArgumentException; 23 | import org.apache.commons.cli.CommandLine; 24 | import org.apache.commons.cli.DefaultParser; 25 | import org.apache.commons.cli.HelpFormatter; 26 | import org.apache.commons.cli.Option; 27 | import org.apache.commons.cli.Options; 28 | import org.apache.commons.cli.ParseException; 29 | 30 | import javax.annotation.concurrent.ThreadSafe; 31 | import java.util.ArrayList; 32 | import java.util.Collections; 33 | import java.util.List; 34 | import java.util.Objects; 35 | 36 | /** 37 | * @since 3.0.0 38 | */ 39 | @ThreadSafe 40 | public class ArgumentsParser { 41 | 42 | private final Options options; 43 | 44 | private final List arguments; 45 | 46 | public ArgumentsParser(Argument... arguments) throws DuplicatedArgumentException { 47 | Options options = new Options(); 48 | 49 | for (Argument argument : arguments) { 50 | Option option = argument.getOption(); 51 | 52 | if (options.getOption(option.getOpt()) != null) { 53 | throw new DuplicatedArgumentException("ArgumentsParser received an argument registered already: %s(option=[%s,%s])", 54 | argument.getClass().getSimpleName(), option.getOpt(), option.getLongOpt()); 55 | } 56 | 57 | options.addOption(option); 58 | } 59 | 60 | this.options = options; 61 | this.arguments = List.of(arguments); 62 | } 63 | 64 | /** 65 | * Parses program arguments. 66 | * 67 | * @param args program arguments 68 | * @return arguments 69 | * @throws ParsingArgumentException if failed to parse argument 70 | */ 71 | public List parse(String... args) throws ParsingArgumentException { 72 | CommandLine cmd; 73 | 74 | try { 75 | cmd = new DefaultParser().parse(this.options, args); 76 | } catch (ParseException e) { 77 | // Without required options or arguments, the program will exit. 78 | new HelpFormatter().printHelp(" ", null, this.options, "", true); 79 | throw new ParsingArgumentException(e, "Failed to parse argument: %s", ArrayUtils.toString(args)); 80 | } 81 | 82 | List clones = new ArrayList<>(); 83 | 84 | for (Argument argument : this.arguments) { 85 | String optionValue = getOptionValue(cmd, argument.getOption()); 86 | 87 | // Checks validity of the option value for each argument. 88 | argument.validate(optionValue); 89 | 90 | // Defensive copy. 91 | // This needs high cost so that will be performed after checking the argument has no problem. 92 | Argument clone = ReflectionUtils.instantiate(argument.getClass()); 93 | 94 | clone.setValue(optionValue); 95 | clones.add(clone); 96 | } 97 | 98 | return Collections.unmodifiableList(clones); 99 | } 100 | 101 | // ------------------------------------------------------------------------------------------------- 102 | 103 | private static String getOptionValue(CommandLine cmd, Option option) { 104 | String optionValue = cmd.getOptionValue(option.getOpt()); 105 | 106 | // When option with required argument. 107 | if (option.hasArg() && !option.hasOptionalArg()) { 108 | return Objects.requireNonNullElse(optionValue, ""); 109 | } 110 | 111 | // When option has optional argument. 112 | if (optionValue != null) { 113 | return optionValue; 114 | } 115 | 116 | // When option without argument. 117 | boolean hasOption = cmd.hasOption(option.getOpt()); 118 | return String.valueOf(hasOption); 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/http/interceptor/FabricatedHeadersInterceptor.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.http.interceptor; 18 | 19 | import okhttp3.Interceptor; 20 | import okhttp3.Request; 21 | import okhttp3.Response; 22 | 23 | import java.io.IOException; 24 | import java.time.LocalDate; 25 | import java.time.Period; 26 | import java.util.Locale; 27 | import java.util.Random; 28 | import java.util.UUID; 29 | import java.util.concurrent.ThreadLocalRandom; 30 | import java.util.concurrent.atomic.AtomicReference; 31 | 32 | public class FabricatedHeadersInterceptor implements Interceptor { 33 | 34 | private final AtomicReference locale = new AtomicReference<>(Locale.ROOT); 35 | 36 | private final AtomicReference accessToken = new AtomicReference<>(new UUID(0, 0)); 37 | 38 | @Override 39 | public Response intercept(Chain chain) throws IOException { 40 | Locale locale = this.locale.get(); 41 | 42 | Request.Builder builder = chain.request() 43 | .newBuilder() 44 | .addHeader("accept", "application/json; charset=utf-8") 45 | // DO NOT ADD HEADER "accept-encoding: gzip, deflate, br". 46 | // java.lang.IllegalStateException: 47 | // Expected BEGIN_ARRAY but was STRING at line 1 column 1 path $ 48 | // 49 | // .addHeader("accept-encoding", "gzip, deflate, br") 50 | .addHeader("cache-control", "no-cache") 51 | .addHeader("pragma", "no-cache") 52 | // Fabricated user agent. 53 | .addHeader("user-agent", randomizeUserAgent()) 54 | // User-defined header used on lezhin only. 55 | .addHeader("x-lz-adult", "0") 56 | .addHeader("x-lz-allowadult", "true") 57 | .addHeader("x-lz-country", locale.getCountry().toLowerCase(locale)) 58 | .addHeader("x-lz-locale", locale.getLanguage() + '-' + locale.getCountry()); 59 | 60 | UUID accessToken = this.accessToken.get(); 61 | if (!accessToken.equals(new UUID(0, 0))) { 62 | builder.addHeader("authorization", "Bearer " + accessToken); 63 | } 64 | 65 | Request fabricatedRequest = builder.build(); 66 | 67 | return chain.proceed(fabricatedRequest); 68 | } 69 | 70 | public void setLocale(Locale locale) { 71 | this.locale.set(locale); 72 | } 73 | 74 | public void setAccessToken(UUID accessToken) { 75 | this.accessToken.set(accessToken); 76 | } 77 | 78 | // ------------------------------------------------------------------------------------------------- 79 | 80 | private static String randomizeUserAgent() { 81 | Random random = ThreadLocalRandom.current(); 82 | Period period = Period.between(LocalDate.of(2005, 1, 1), LocalDate.now().withDayOfMonth(1)); 83 | 84 | // 2005-01-01: 1, 2022-11-01: 107 85 | int majorVersion = (int) period.toTotalMonths() / 2; 86 | // between 2000 and 5499 87 | int minorVersion = random.nextInt(3500) + 2000; 88 | // between 0 and 159 89 | int bugfixVersion = random.nextInt(160); 90 | 91 | String chromeVersion = String.format("%d.0.%d.%d", majorVersion, minorVersion, bugfixVersion); 92 | 93 | String os; 94 | switch (random.nextInt(5)) { 95 | case 0: 96 | os = "Windows NT 10.0; Win64; x64"; 97 | break; 98 | case 1: 99 | os = "Macintosh; Intel Mac OS X 10_15_7"; 100 | break; 101 | case 2: 102 | os = "X11; Ubuntu; Linux x86_64"; 103 | break; 104 | case 3: 105 | os = "Macintosh; Intel Mac OS X 10_14_6"; 106 | break; 107 | case 4: 108 | os = "X11; Linux x86_64"; 109 | break; 110 | default: 111 | throw new AssertionError("Never thrown"); 112 | } 113 | 114 | return String.format("Mozilla/5.0 (%s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36", os, chromeVersion); 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Lezhin Comics Downloader 3 |

4 | 5 |

Lezhin Comics Downloader

6 | 7 |

Downloader for lezhin comics

8 | 9 |

10 | GitHub All Releases 11 | GitHub Releases 12 | 13 | Latest release 14 | 15 |
16 | 17 | Sonarcloud Quality Gate Status 18 | 19 | 20 | Sonarcloud Maintainability Rating 21 | 22 | 23 | Codacy grade 24 | 25 | jdk11 26 |

27 | 28 | # Preview 29 | 30 | preview 31 | 32 |

This is downloader that helps you to login and downloads the specified comic for all lezhin-comics even adults.

33 |

The user is responsible for everything that happens using this program.

34 |

35 | 36 | # Getting started 37 | 38 | ## Pre-requirements 39 | 40 | 1. Check if chrome browser was installed in your device or download it [here](https://www.google.com/chrome). 41 | 42 | 2. Check your chrome browser version with this URI `chrome://version`. 43 | 44 | (The first line is the version. e.g. 83.0.4103.116) 45 | 46 | 3. Download the `chrome driver` that matches its version and your device 47 | OS [here](https://chromedriver.chromium.org/downloads) and decompress it. 48 | 49 | 4. Check if your JRE(or JDK) version is 11 or higher. If you don't have, install it. 50 | 51 | 5. Download the latest 52 | released `lezhin-comics-downloader.jar` [here](https://github.com/ImSejin/lezhin-comics-downloader/releases). 53 | 54 | 6. Download `config.ini` [here](https://raw.githubusercontent.com/ImSejin/lezhin-comics-downloader/master/config.ini) 55 | and write your account in the file. 56 | 57 | 7. Place three files in the same path. 58 | 59 | 8. Use the following command to run the downloader. 60 | 61 |

62 | 63 | ## Usage 64 | 65 | ```bash 66 | java -jar {JAR filename} -l= -n= [-r= -j -s -d] 67 | ``` 68 | 69 | - *locale language (required)*: language of lezhin platform you want to download the webtoon on. 70 | 71 | - **ko** : korean 72 | - **en** : english 73 | - **ja** : japanese 74 | 75 | - *content name (required)*: webtoon name you want to download. 76 | 77 |

78 | comic name 79 |

80 | 81 | - *episode range (optional)*: range of episodes you want to download. 82 | - __skipped__ : all episodes 83 | - __n~__ : from ep.N to the last episode 84 | - __~n__ : from the first episode to ep.N 85 | - __m~n__ : from ep.M to ep.N 86 | - jpg (optional): save images as JPEG format (default: WEBP format). 87 | - single threading (optional): download images on single-thread; useful if some images are missing (default: multi-threading). 88 | - debug (optional): enables debugging mode. 89 | 90 |

91 | 92 | # Examples 93 | 94 | ```bash 95 | java -jar lezhin-comics-downloader.jar -l=en -n=appetite 96 | ``` 97 | 98 | Downloads all episodes of the comic named appetite. 99 | 100 |
101 | 102 | ```bash 103 | java -jar lezhin-comics-downloader.jar -l=en -n=appetite -r=8~ 104 | ``` 105 | 106 | Downloads the episodes of the comic named appetite from ep.8 to the last. 107 | 108 |
109 | 110 | ```bash 111 | java -jar lezhin-comics-downloader.jar -l=en -n=appetite -r=~25 112 | ``` 113 | 114 | Downloads the episodes of the comic named appetite from the first to ep.25. 115 | 116 |
117 | 118 | ```bash 119 | java -jar lezhin-comics-downloader.jar -l=en -n=appetite -r=1~10 120 | ``` 121 | 122 | Downloads the episodes of the comic named appetite from ep.1 to ep.10. 123 | 124 |
125 | 126 |
127 | 128 | # Build 129 | 130 | ```bash 131 | ./mvnw package 132 | ``` 133 | 134 | Then you will get a file `lezhin-comics-downloader-{version}.jar`. 135 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/api/auth/service/AuthorityService.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.api.auth.service; 18 | 19 | import com.google.gson.annotations.SerializedName; 20 | import io.github.imsejin.dl.lezhin.api.BaseService; 21 | import io.github.imsejin.dl.lezhin.api.auth.model.Authority; 22 | import io.github.imsejin.dl.lezhin.api.auth.model.ServiceRequest; 23 | import io.github.imsejin.dl.lezhin.common.Loggers; 24 | import io.github.imsejin.dl.lezhin.common.PropertyBinder; 25 | import lombok.AccessLevel; 26 | import lombok.Getter; 27 | import lombok.NoArgsConstructor; 28 | import lombok.ToString; 29 | import okhttp3.OkHttpClient; 30 | import retrofit2.Call; 31 | import retrofit2.Response; 32 | import retrofit2.Retrofit; 33 | import retrofit2.converter.gson.GsonConverterFactory; 34 | import retrofit2.http.GET; 35 | import retrofit2.http.Query; 36 | 37 | import java.io.IOException; 38 | import java.util.Locale; 39 | import java.util.Objects; 40 | import java.util.UUID; 41 | 42 | public class AuthorityService extends BaseService { 43 | 44 | private final ServiceInterface serviceInterface; 45 | 46 | public AuthorityService(Locale locale, UUID accessToken) { 47 | super(locale, accessToken); 48 | 49 | OkHttpClient httpClient = BaseService.getHttpClient(); 50 | Retrofit retrofit = new Retrofit.Builder() 51 | .baseUrl("https://www.lezhin.com/lz-api/v2/cloudfront/signed-url/") 52 | .addConverterFactory(GsonConverterFactory.create()) 53 | .client(httpClient) 54 | .build(); 55 | 56 | this.serviceInterface = retrofit.create(ServiceInterface.class); 57 | } 58 | 59 | public Authority getAuthForViewEpisode(ServiceRequest request) { 60 | Loggers.getLogger().debug("Request: https://www.lezhin.com/lz-api/v2/cloudfront/signed-url/generate" + 61 | "?contentId={}&episodeId={}&purchased={}&q={}&firstCheckType={}", 62 | request.getContentId(), 63 | request.getEpisodeId(), 64 | request.isPurchased(), 65 | request.getQ(), 66 | request.getFirstCheckType()); 67 | Call call = this.serviceInterface.getAuthForViewEpisode( 68 | request.getContentId(), 69 | request.getEpisodeId(), 70 | request.isPurchased(), 71 | request.getQ(), 72 | request.getFirstCheckType()); 73 | 74 | Response response; 75 | try { 76 | response = call.execute(); 77 | } catch (IOException e) { 78 | throw new RuntimeException(e.getMessage(), e); 79 | } 80 | 81 | AuthResponse authResponse = Objects.requireNonNull(response.body()); 82 | return PropertyBinder.INSTANCE.toAuthority(authResponse.getAuthData()); 83 | } 84 | 85 | // ------------------------------------------------------------------------------------------------- 86 | 87 | private interface ServiceInterface { 88 | @GET("generate") 89 | Call getAuthForViewEpisode( 90 | @Query("contentId") Long contentId, 91 | @Query("episodeId") Long episodeId, 92 | @Query("purchased") boolean purchased, 93 | @Query("q") int q, 94 | @Query("firstCheckType") Character firstCheckType); 95 | } 96 | 97 | // ------------------------------------------------------------------------------------------------- 98 | 99 | @Getter 100 | @ToString 101 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 102 | public static final class AuthResponse { 103 | @SerializedName("code") 104 | private Integer code; 105 | 106 | @SerializedName("description") 107 | private String description; 108 | 109 | @SerializedName("data") 110 | private AuthData authData; 111 | } 112 | 113 | @Getter 114 | @ToString 115 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 116 | public static final class AuthData { 117 | @SerializedName("Policy") 118 | private String policy; 119 | 120 | @SerializedName("Signature") 121 | private String signature; 122 | 123 | @SerializedName("Key-Pair-Id") 124 | private String keyPairId; 125 | 126 | @SerializedName("expiredAt") 127 | private Long expiredAt; 128 | 129 | @SerializedName("now") 130 | private Long now; 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/api/image/service/EpisodeImageCountService.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.api.image.service; 18 | 19 | import com.google.gson.annotations.SerializedName; 20 | import io.github.imsejin.common.util.CollectionUtils; 21 | import io.github.imsejin.dl.lezhin.api.BaseService; 22 | import io.github.imsejin.dl.lezhin.attribute.impl.Content.Episode; 23 | import io.github.imsejin.dl.lezhin.common.Loggers; 24 | import lombok.AccessLevel; 25 | import lombok.Getter; 26 | import lombok.NoArgsConstructor; 27 | import lombok.ToString; 28 | import retrofit2.Call; 29 | import retrofit2.Response; 30 | import retrofit2.Retrofit; 31 | import retrofit2.converter.gson.GsonConverterFactory; 32 | import retrofit2.http.GET; 33 | import retrofit2.http.Path; 34 | 35 | import java.io.IOException; 36 | import java.util.Collections; 37 | import java.util.List; 38 | import java.util.Locale; 39 | import java.util.Map; 40 | import java.util.UUID; 41 | 42 | import static java.util.stream.Collectors.toMap; 43 | 44 | /** 45 | * @since 3.0.0 46 | */ 47 | public class EpisodeImageCountService extends BaseService { 48 | 49 | private final ServiceInterface serviceInterface; 50 | 51 | public EpisodeImageCountService(UUID accessToken) { 52 | // Supported on only korea platform. 53 | super(Locale.KOREA, accessToken); 54 | 55 | Retrofit retrofit = new Retrofit.Builder() 56 | .baseUrl("https://api.lezhin.com/") 57 | .addConverterFactory(GsonConverterFactory.create(BaseService.getGson())) 58 | .client(BaseService.getHttpClient()) 59 | .build(); 60 | 61 | this.serviceInterface = retrofit.create(ServiceInterface.class); 62 | } 63 | 64 | public Map getImageCountMap(String contentAlias) { 65 | Loggers.getLogger().debug("Request: https://api.lezhin.com/episodes/{}", contentAlias); 66 | Call> call = this.serviceInterface.getEpisodes(contentAlias); 67 | 68 | Response> response; 69 | try { 70 | response = call.execute(); 71 | } catch (IOException e) { 72 | throw new RuntimeException(e.getMessage(), e); 73 | } 74 | 75 | List models = response.body(); 76 | if (CollectionUtils.isNullOrEmpty(models)) { 77 | throw new IllegalArgumentException("Failed to get number of episode images: " + contentAlias); 78 | } 79 | 80 | Map imageCountMap = models.stream() 81 | .collect(toMap(EpisodeModel::getName, EpisodeModel::getImageCount)); 82 | return Collections.unmodifiableMap(imageCountMap); 83 | } 84 | 85 | public int getImageCount(String contentAlias, Episode episode) { 86 | Loggers.getLogger().debug("Request: https://api.lezhin.com/episodes/{}/{}", contentAlias, episode.getName()); 87 | Call call = this.serviceInterface.getEpisode(contentAlias, episode.getName()); 88 | 89 | Response response; 90 | try { 91 | response = call.execute(); 92 | } catch (IOException e) { 93 | throw new RuntimeException(e.getMessage(), e); 94 | } 95 | 96 | EpisodeModel model = response.body(); 97 | if (model == null) { 98 | throw new IllegalArgumentException("Failed to get number of episode images: " 99 | + contentAlias + '/' + episode.getName()); 100 | } 101 | 102 | return model.getImageCount(); 103 | } 104 | 105 | // ------------------------------------------------------------------------------------------------- 106 | 107 | private interface ServiceInterface { 108 | @GET("episodes/{contentAlias}") 109 | Call> getEpisodes(@Path("contentAlias") String contentAlias); 110 | 111 | @GET("episodes/{contentAlias}/{name}") 112 | Call getEpisode( 113 | @Path("contentAlias") String contentAlias, 114 | @Path("name") String name); 115 | } 116 | 117 | // ------------------------------------------------------------------------------------------------- 118 | 119 | @Getter 120 | @ToString 121 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 122 | private static final class EpisodeModel { 123 | @SerializedName("episodeId") 124 | String id; 125 | 126 | @SerializedName("seq") 127 | Integer seq; 128 | 129 | @SerializedName("name") 130 | String name; 131 | 132 | @SerializedName("comicId") 133 | String contentAlias; 134 | 135 | @SerializedName("cut") 136 | Integer imageCount; 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/process/framework/ProcessorOrderResolver.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.process.framework; 2 | 3 | import io.github.imsejin.common.model.graph.DirectedGraph; 4 | import io.github.imsejin.common.model.graph.Graph; 5 | import io.github.imsejin.common.model.graph.traverse.DepthFirstIterator; 6 | import io.github.imsejin.common.util.ClassUtils; 7 | import io.github.imsejin.common.util.CollectionUtils; 8 | import io.github.imsejin.common.util.StreamUtils; 9 | import io.github.imsejin.dl.lezhin.annotation.ProcessSpecification; 10 | import io.github.imsejin.dl.lezhin.exception.InvalidProcessSpecificationException; 11 | import io.github.imsejin.dl.lezhin.exception.ProcessorNotSpecifyException; 12 | import io.github.imsejin.dl.lezhin.process.Processor; 13 | 14 | import java.util.ArrayList; 15 | import java.util.Collection; 16 | import java.util.Collections; 17 | import java.util.Iterator; 18 | import java.util.List; 19 | import java.util.Set; 20 | 21 | import static java.util.stream.Collectors.toUnmodifiableList; 22 | 23 | public final class ProcessorOrderResolver { 24 | 25 | public static List> resolve(Set> processorTypes) 26 | throws ProcessorNotSpecifyException, InvalidProcessSpecificationException { 27 | if (CollectionUtils.isNullOrEmpty(processorTypes)) { 28 | return Collections.emptyList(); 29 | } 30 | 31 | validate(processorTypes); 32 | 33 | Graph> graph = createDependencyGraph(processorTypes); 34 | 35 | // Validates a dependency graph. 36 | if (graph.getVertexSize() != graph.getPathLength() + 1) { 37 | throw new InvalidProcessSpecificationException("Not linear dependency graph of process specification: %s", graph); 38 | } 39 | 40 | Class startingProcessorType = processorTypes.stream() 41 | .filter(it -> it.getAnnotation(ProcessSpecification.class).dependsOn() == ProcessSpecification.INDEPENDENT) 42 | .findFirst().orElseThrow(); 43 | Iterator> iterator = new DepthFirstIterator<>(graph, startingProcessorType); 44 | 45 | return StreamUtils.toStream(iterator).collect(toUnmodifiableList()); 46 | } 47 | 48 | // ------------------------------------------------------------------------------------------------- 49 | 50 | private static void validate(Set> processorTypes) 51 | throws ProcessorNotSpecifyException, InvalidProcessSpecificationException { 52 | Collection> rootSet = new ArrayList<>(); 53 | 54 | for (Class processorType : processorTypes) { 55 | ProcessSpecification spec = processorType.getAnnotation(ProcessSpecification.class); 56 | if (spec == null) { 57 | throw new ProcessorNotSpecifyException("There is a processor that doesn't specify its specification; " + 58 | "Annotate @ProcessSpecification on %s", processorType); 59 | } 60 | 61 | Class dependentType = spec.dependsOn(); 62 | if (dependentType == ProcessSpecification.INDEPENDENT) { 63 | rootSet.add(processorType); 64 | continue; 65 | } 66 | 67 | if (dependentType == Processor.class 68 | || ClassUtils.isAbstractClass(dependentType) 69 | || !Processor.class.isAssignableFrom(dependentType)) { 70 | throw new InvalidProcessSpecificationException("@ProcessSpecification.dependsOn must be a implementation of Processor: " + 71 | "@ProcessSpecification(dependsOn = %s.class) %s", dependentType.getName(), processorType.getName()); 72 | } 73 | 74 | if (dependentType == processorType) { 75 | throw new InvalidProcessSpecificationException("There is a self-referential @ProcessSpecification.dependsOn: %s", processorType); 76 | } 77 | } 78 | 79 | if (rootSet.isEmpty()) { 80 | throw new InvalidProcessSpecificationException("There is no @ProcessSpecification as a starting process; " + 81 | "Must be only one @ProcessSpecification whose dependsOn is %s", ProcessSpecification.INDEPENDENT); 82 | } 83 | 84 | if (rootSet.size() > 1) { 85 | throw new InvalidProcessSpecificationException("There are two or more @ProcessSpecification as a starting process; " + 86 | "Must be only one @ProcessSpecification whose dependsOn is %s: %s", ProcessSpecification.INDEPENDENT, rootSet); 87 | } 88 | } 89 | 90 | @SuppressWarnings("unchecked") 91 | private static Graph> createDependencyGraph(Set> processorTypes) { 92 | Graph> graph = new DirectedGraph<>(); 93 | processorTypes.forEach(graph::addVertex); 94 | 95 | for (Class processorType : processorTypes) { 96 | ProcessSpecification spec = processorType.getAnnotation(ProcessSpecification.class); 97 | 98 | Class dependentType = spec.dependsOn(); 99 | if (dependentType == ProcessSpecification.INDEPENDENT) { 100 | continue; 101 | } 102 | 103 | graph.addEdge((Class) dependentType, processorType); 104 | } 105 | 106 | return graph; 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/argument/ArgumentsParserSpec.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.argument 18 | 19 | import io.github.imsejin.dl.lezhin.argument.impl.* 20 | import io.github.imsejin.dl.lezhin.exception.DuplicatedArgumentException 21 | import io.github.imsejin.dl.lezhin.exception.ParsingArgumentException 22 | import org.apache.commons.cli.Option 23 | import spock.lang.Specification 24 | import spock.lang.Subject 25 | 26 | @Subject(ArgumentsParser) 27 | class ArgumentsParserSpec extends Specification { 28 | 29 | def "Failed to create parser due to duplicated argument"() { 30 | given: 31 | def arguments = (0..1).collect { 32 | def argument = Mock(Argument) 33 | argument.option >> { Option.builder("alpha").build() } 34 | argument 35 | } 36 | 37 | when: 38 | new ArgumentsParser(arguments as Argument[]) 39 | 40 | then: 41 | def e = thrown(DuplicatedArgumentException) 42 | e.message ==~ /ArgumentsParser received an argument registered already: .+\(option=\[alpha,\w+]\)/ 43 | } 44 | 45 | def "Failed to parse program arguments due to invalid one"() { 46 | given: 47 | def argument = Mock(Argument) 48 | argument.option >> { Option.builder("alpha").build() } 49 | 50 | when: 51 | def parser = new ArgumentsParser(argument) 52 | def args = programArgs.split(" ") 53 | parser.parse(args) 54 | 55 | then: 56 | def e = thrown(ParsingArgumentException) 57 | e.message == "Failed to parse argument: $args" 58 | 59 | where: 60 | programArgs << ["-B", "--alpha --beta=B --gamma=G", "--delta=1001"] 61 | } 62 | 63 | def "Parses program arguments"() { 64 | given: 65 | def argument = Mock(Argument) 66 | argument.option >> { Option.builder(longName[0]).longOpt(longName).hasArg(value != null).valueSeparator().build() } 67 | argument.value >> { value } 68 | 69 | when: 70 | def parser = new ArgumentsParser(argument) 71 | def arguments = parser.parse(programArgs.split(" ")) 72 | 73 | then: 74 | !arguments.isEmpty() 75 | argument !== arguments[0] 76 | 77 | where: 78 | longName | value | programArgs 79 | "alpha" | null | "--$longName" 80 | "beta" | "3.141592" | "--$longName=$value" 81 | "gamma" | null | "--$longName" 82 | "delta" | "lezhin-comics" | "--$longName=$value" 83 | } 84 | 85 | def "Parses actual program arguments"() { 86 | given: 87 | def arguments = [new Language(), new ContentName(), new EpisodeRange(), new ImageFormat(), new SingleThreading(), new DebugMode()] 88 | 89 | when: 90 | def parser = new ArgumentsParser(arguments as Argument[]) 91 | def actual = parser.parse(programArgs.split(" ")) 92 | 93 | then: 94 | !actual.isEmpty() 95 | actual != arguments 96 | actual.size() == arguments.size() 97 | actual == expected 98 | 99 | where: 100 | programArgs | expected 101 | "-l=ko -n=alpha" | [new Language(value: "ko"), new ContentName(value: "alpha"), new EpisodeRange(value: ""), new ImageFormat(value: "false"), new SingleThreading(value: "false"), new DebugMode(value: "false")] 102 | "-l=en -n=beta -r=8~" | [new Language(value: "en"), new ContentName(value: "beta"), new EpisodeRange(value: "8~"), new ImageFormat(value: "false"), new SingleThreading(value: "false"), new DebugMode(value: "false")] 103 | "-l=ja -n=gamma -j" | [new Language(value: "ja"), new ContentName(value: "gamma"), new EpisodeRange(value: ""), new ImageFormat(value: "true"), new SingleThreading(value: "false"), new DebugMode(value: "false")] 104 | "-l=ja -n=gamma -j -s" | [new Language(value: "ja"), new ContentName(value: "gamma"), new EpisodeRange(value: ""), new ImageFormat(value: "true"), new SingleThreading(value: "true"), new DebugMode(value: "false")] 105 | "-l=ko -n=delta -d -s=true" | [new Language(value: "ko"), new ContentName(value: "delta"), new EpisodeRange(value: ""), new ImageFormat(value: "false"), new SingleThreading(value: "true"), new DebugMode(value: "true")] 106 | "-l=ko -n=delta -d=true" | [new Language(value: "ko"), new ContentName(value: "delta"), new EpisodeRange(value: ""), new ImageFormat(value: "false"), new SingleThreading(value: "false"), new DebugMode(value: "true")] 107 | "-l=en -n=epsilon -r=~25 -s=false -j" | [new Language(value: "en"), new ContentName(value: "epsilon"), new EpisodeRange(value: "~25"), new ImageFormat(value: "true"), new SingleThreading(value: "false"), new DebugMode(value: "false")] 108 | "-l=ja -n=zeta -r=1~10 -j -d=false" | [new Language(value: "ja"), new ContentName(value: "zeta"), new EpisodeRange(value: "1~10"), new ImageFormat(value: "true"), new SingleThreading(value: "false"), new DebugMode(value: "false")] 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/process/impl/LoginProcessor.java: -------------------------------------------------------------------------------- 1 | package io.github.imsejin.dl.lezhin.process.impl; 2 | 3 | import io.github.imsejin.common.util.StringUtils; 4 | import io.github.imsejin.dl.lezhin.annotation.ProcessSpecification; 5 | import io.github.imsejin.dl.lezhin.attribute.impl.Authentication; 6 | import io.github.imsejin.dl.lezhin.browser.WebBrowser; 7 | import io.github.imsejin.dl.lezhin.common.Loggers; 8 | import io.github.imsejin.dl.lezhin.exception.LoginException; 9 | import io.github.imsejin.dl.lezhin.http.url.URIs; 10 | import io.github.imsejin.dl.lezhin.process.ProcessContext; 11 | import io.github.imsejin.dl.lezhin.process.Processor; 12 | import org.openqa.selenium.By; 13 | import org.openqa.selenium.TimeoutException; 14 | import org.openqa.selenium.WebElement; 15 | import org.openqa.selenium.chrome.ChromeDriver; 16 | 17 | import java.net.URI; 18 | import java.util.Locale; 19 | import java.util.Map; 20 | 21 | /** 22 | * Processor for login 23 | * 24 | *

This is the first place to run a chrome driver. First, {@link ChromeDriver} finds 25 | * the root element that is {@code

} and then 26 | * it finds the three element in the root. 27 | * 28 | *
    29 | *
  1. {@code }
  2. 30 | *
  3. {@code }
  4. 31 | *
  5. {@code }
  6. 32 | *
33 | * 34 | *

{@link ChromeDriver} inputs username and password to the first and second element. 35 | * When input tags are filled by username and password, it clicks the third element so that login. 36 | * 37 | * @since 3.0.0 38 | */ 39 | @ProcessSpecification(dependsOn = ConfigurationFileProcessor.class) 40 | public class LoginProcessor implements Processor { 41 | 42 | private static final Map BASE_URL_MAP = Map.ofEntries( 43 | Map.entry(Locale.KOREA, "https://www.lezhin.com/"), 44 | Map.entry(Locale.US, "https://www.lezhinus.com/"), 45 | Map.entry(Locale.JAPAN, "https://www.lezhin.jp/") 46 | ); 47 | 48 | @Override 49 | public Void process(ProcessContext context) throws LoginException { 50 | // Resolves an implementation for the locale. 51 | Locale locale = context.getLanguage().getValue(); 52 | String baseUrl = BASE_URL_MAP.get(locale); 53 | 54 | if (baseUrl == null) { 55 | throw new AssertionError("ProcessContext.language.value is not recognized: " + locale); 56 | } 57 | 58 | // Starts to run web browser. 59 | WebBrowser.run(); 60 | 61 | // Goes to login page. 62 | gotoLoginPage(baseUrl, locale); 63 | 64 | // Waits for DOM to complete the rendering. 65 | WebElement loginForm = waitForRenderingLoginPage(); 66 | 67 | // Inputs authentication into the element. 68 | Authentication authentication = context.getAuthentication(); 69 | inputUsername(loginForm, authentication.getUsername()); 70 | inputPassword(loginForm, authentication.getPassword()); 71 | 72 | // Erases the password from memory for security. 73 | authentication.erasePassword(); 74 | 75 | String loginPageUrl = WebBrowser.getCurrentUrl(); 76 | 77 | // Submits login form. 78 | submit(loginForm); 79 | 80 | validate(loginPageUrl); 81 | 82 | // Return value will be ignored by ProcessContext. 83 | return null; 84 | } 85 | 86 | // ------------------------------------------------------------------------------------------------- 87 | 88 | private static void gotoLoginPage(String baseUrl, Locale locale) { 89 | URI loginPageUri = URI.create(baseUrl); 90 | String path = URIs.LOGIN.get(locale.getLanguage()); 91 | loginPageUri = loginPageUri.resolve(path); 92 | 93 | Loggers.getLogger().info("Request login page: {}", loginPageUri); 94 | WebBrowser.request(loginPageUri); 95 | } 96 | 97 | private static WebElement waitForRenderingLoginPage() { 98 | Loggers.getLogger().debug("Wait up to {} sec for login element to be rendered", WebBrowser.DEFAULT_TIMEOUT_SECONDS); 99 | 100 | return WebBrowser.waitForVisibilityOfElement(By.xpath( 101 | "//form[@id='email' and contains(@action, '/login') and @method='post']")); 102 | } 103 | 104 | private static void inputUsername(WebElement loginForm, String username) { 105 | Loggers.getLogger().debug("Send username: {}", username); 106 | 107 | WebElement usernameInput = loginForm.findElement(By.xpath(".//input[@id='login-email']")); 108 | usernameInput.clear(); 109 | usernameInput.sendKeys(username); 110 | } 111 | 112 | private static void inputPassword(WebElement loginForm, String password) { 113 | String maskedPassword = password.charAt(0) + StringUtils.repeat('*', password.length() - 1); 114 | Loggers.getLogger().debug("Send password: {}", maskedPassword); 115 | 116 | WebElement passwordInput = loginForm.findElement(By.xpath(".//input[@id='login-password']")); 117 | passwordInput.clear(); 118 | passwordInput.sendKeys(password); 119 | } 120 | 121 | private static void submit(WebElement loginForm) { 122 | Loggers.getLogger().debug("Try to login"); 123 | WebElement submitButton = loginForm.findElement(By.xpath(".//button[@type='submit']")); 124 | submitButton.click(); 125 | } 126 | 127 | private static void validate(String loginPageUrl) throws LoginException { 128 | try { 129 | // Waits for DOM to complete the rendering. 130 | Loggers.getLogger().debug("Wait up to {} sec for main page to be rendered", WebBrowser.DEFAULT_TIMEOUT_SECONDS); 131 | WebBrowser.waitForVisibilityOfElement(By.xpath("//main[@id='main' and @class='lzCntnr lzCntnr--home']")); 132 | } catch (TimeoutException e) { 133 | // When failed to login because of other problems. 134 | if (!WebBrowser.getCurrentUrl().equals(loginPageUrl)) { 135 | throw new LoginException(e, "Failed to login"); 136 | } 137 | 138 | // When failed to login because of invalid account information. 139 | String errorCode = WebBrowser.evaluate("window.__LZ_ERROR_CODE__", String.class); 140 | 141 | throw new LoginException(errorCode); 142 | } 143 | } 144 | 145 | } 146 | -------------------------------------------------------------------------------- /src/test/groovy/io/github/imsejin/dl/lezhin/process/ProcessContextSpec.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.process 18 | 19 | import io.github.imsejin.dl.lezhin.argument.impl.* 20 | import io.github.imsejin.dl.lezhin.attribute.Attribute 21 | import spock.lang.Specification 22 | import spock.lang.Subject 23 | 24 | @Subject(ProcessContext) 25 | class ProcessContextSpec extends Specification { 26 | 27 | def "Defines all attributes of context"() { 28 | given: 29 | def fields = ProcessContext.FIELDS 30 | def types = fields*.type 31 | 32 | expect: "All attributes must be unique." 33 | !types.isEmpty() 34 | types.grep(Attribute) == types 35 | types.unique(false) == types 36 | } 37 | 38 | def "Creates new context with attributes"() { 39 | given: 40 | def attributes = [language, contentName, episodeRange, imageFormat, debugMode, singleThreading] 41 | 42 | when: 43 | def context = ProcessContext.create(attributes as Object[]) 44 | 45 | then: 46 | context != null 47 | context.language === language 48 | context.contentName === contentName 49 | context.episodeRange === episodeRange 50 | context.imageFormat === imageFormat 51 | context.debugMode === debugMode 52 | context.singleThreading === singleThreading 53 | 54 | where: 55 | language | contentName | episodeRange | imageFormat | debugMode | singleThreading 56 | new Language(value: "ko") | new ContentName(value: "alpha") | new EpisodeRange(value: "") | new ImageFormat(value: "false") | new DebugMode(value: "false") | new SingleThreading(value: "true") 57 | new Language(value: "en") | new ContentName(value: "epsilon") | new EpisodeRange(value: "~25") | new ImageFormat(value: "true") | new DebugMode(value: "false") | new SingleThreading(value: "true") 58 | new Language(value: "ja") | new ContentName(value: "zeta") | new EpisodeRange(value: "1~10") | new ImageFormat(value: "true") | new DebugMode(value: "true") | new SingleThreading(value: "false") 59 | } 60 | 61 | def "Returns given context with redundant attributes"() { 62 | given: 63 | def context = ProcessContext.create() 64 | 65 | when: 66 | def newContext = ProcessContext.of(context) 67 | 68 | then: 69 | newContext != null 70 | newContext === context 71 | 72 | when: 73 | newContext = ProcessContext.of(context, null, null) 74 | 75 | then: 76 | newContext != null 77 | newContext === context 78 | } 79 | 80 | def "Creates new context with other context"() { 81 | given: 82 | def attributes = [language, contentName, episodeRange, imageFormat, debugMode, singleThreading] 83 | def context = ProcessContext.create(attributes as Object[]) 84 | 85 | when: 86 | def newContext = ProcessContext.of(context) 87 | 88 | then: 89 | newContext != null 90 | newContext === context 91 | newContext.language === language 92 | newContext.contentName === contentName 93 | newContext.episodeRange === episodeRange 94 | newContext.imageFormat === imageFormat 95 | newContext.debugMode === debugMode 96 | context.singleThreading === singleThreading 97 | 98 | where: 99 | language | contentName | episodeRange | imageFormat | debugMode | singleThreading 100 | new Language(value: "ko") | new ContentName(value: "alpha") | new EpisodeRange(value: "") | new ImageFormat(value: "false") | new DebugMode(value: "false") | new SingleThreading(value: "true") 101 | new Language(value: "en") | new ContentName(value: "epsilon") | new EpisodeRange(value: "~25") | new ImageFormat(value: "true") | new DebugMode(value: "false") | new SingleThreading(value: "true") 102 | new Language(value: "ja") | new ContentName(value: "zeta") | new EpisodeRange(value: "1~10") | new ImageFormat(value: "true") | new DebugMode(value: "true") | new SingleThreading(value: "false") 103 | } 104 | 105 | def "Adds attributes"() { 106 | given: 107 | def context = ProcessContext.create(originAttributes.values() as Object[]) 108 | 109 | when: 110 | context.add(newAttributes.values() as Object[]) 111 | 112 | then: 113 | newAttributes.keySet().forEach { 114 | assert context[it] == newAttributes[it] 115 | } 116 | (originAttributes.keySet() - newAttributes.keySet()).forEach { 117 | assert context[it] == originAttributes[it] 118 | } 119 | (originAttributes.keySet().intersect(newAttributes.keySet())).forEach { 120 | assert context[it] == newAttributes[it] 121 | assert context[it] != originAttributes[it] 122 | } 123 | 124 | where: 125 | originAttributes || newAttributes 126 | [language: new Language(value: "ko"), contentName: new ContentName(value: "alpha")] || [contentName: new ContentName(value: "beta")] 127 | [episodeRange: new EpisodeRange(value: "")] || [episodeRange: new EpisodeRange(value: "1~10"), imageFormat: new ImageFormat(value: "true")] 128 | [language: new Language(value: "en"), debugMode: new DebugMode(value: "true")] || [episodeRange: new EpisodeRange(value: "~8")] 129 | [contentName: new ContentName(value: "beta"), episodeRange: new EpisodeRange(value: "")] || [imageFormat: new ImageFormat(value: "true"), debugMode: new DebugMode(value: "false")] 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /src/main/java/io/github/imsejin/dl/lezhin/argument/impl/EpisodeRange.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Sejin Im 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.github.imsejin.dl.lezhin.argument.impl; 18 | 19 | import io.github.imsejin.dl.lezhin.argument.Argument; 20 | import io.github.imsejin.dl.lezhin.attribute.Attribute; 21 | import lombok.EqualsAndHashCode; 22 | import lombok.Getter; 23 | import lombok.ToString; 24 | import org.apache.commons.cli.Option; 25 | 26 | import java.util.Objects; 27 | import java.util.function.Consumer; 28 | import java.util.regex.Pattern; 29 | 30 | /** 31 | * Range of episode to download 32 | * 33 | * @since 3.0.0 34 | */ 35 | @Getter 36 | @ToString 37 | @EqualsAndHashCode(callSuper = false) 38 | public class EpisodeRange extends Argument implements Attribute { 39 | 40 | private static final Pattern PATTERN = Pattern.compile("\\d+|\\d+~(\\d+)?|(\\d+)?~\\d+"); 41 | 42 | private Integer startNumber; 43 | 44 | private Integer endNumber; 45 | 46 | private RangeType rangeType; 47 | 48 | @Override 49 | public String getValue() { 50 | if (this.startNumber == null && this.endNumber == null) { 51 | return "*"; 52 | } 53 | 54 | if (Objects.equals(this.startNumber, this.endNumber)) { 55 | return String.valueOf(this.startNumber); 56 | } 57 | 58 | Object startNumber = Objects.requireNonNullElse(this.startNumber, ""); 59 | Object endNumber = Objects.requireNonNullElse(this.endNumber, ""); 60 | 61 | return startNumber + "~" + endNumber; 62 | } 63 | 64 | // ------------------------------------------------------------------------------------------------- 65 | 66 | @Override 67 | protected Option getOption() { 68 | return Option.builder("r") 69 | .longOpt("range") 70 | .desc("Range of episodes you want to download") 71 | .hasArg() 72 | .valueSeparator() 73 | .argName("episode_range") 74 | .build(); 75 | } 76 | 77 | @Override 78 | protected void validate(String value) { 79 | if (value == null) { 80 | throw new IllegalArgumentException("Invalid EpisodeRange.value: null"); 81 | } 82 | 83 | if (value.isEmpty()) { 84 | return; 85 | } 86 | 87 | if (!PATTERN.matcher(value).matches()) { 88 | throw new IllegalArgumentException("Invalid EpisodeRange.value: " + value); 89 | } 90 | } 91 | 92 | @Override 93 | protected void setValue(String value) { 94 | for (RangeType rangeType : RangeType.values()) { 95 | if (rangeType.supports(value)) { 96 | rangeType.parse(value).accept(this); 97 | return; 98 | } 99 | } 100 | } 101 | 102 | // ------------------------------------------------------------------------------------------------- 103 | 104 | public enum RangeType { 105 | ALL { 106 | @Override 107 | boolean supports(String value) { 108 | return value.isEmpty(); 109 | } 110 | 111 | @Override 112 | Consumer parse(String value) { 113 | return it -> it.rangeType = this; 114 | } 115 | }, 116 | 117 | ONE { 118 | @Override 119 | boolean supports(String value) { 120 | return !value.contains("~"); 121 | } 122 | 123 | @Override 124 | Consumer parse(String value) { 125 | return it -> { 126 | int number = Integer.parseInt(value); 127 | it.startNumber = number; 128 | it.endNumber = number; 129 | it.rangeType = this; 130 | }; 131 | } 132 | }, 133 | 134 | TO_END { 135 | @Override 136 | boolean supports(String value) { 137 | return value.endsWith("~"); 138 | } 139 | 140 | @Override 141 | Consumer parse(String value) { 142 | return it -> { 143 | String[] numbers = value.split("~"); 144 | it.startNumber = Integer.parseInt(numbers[0]); 145 | it.rangeType = this; 146 | }; 147 | } 148 | }, 149 | 150 | FROM_BEGINNING { 151 | @Override 152 | boolean supports(String value) { 153 | return value.startsWith("~"); 154 | } 155 | 156 | @Override 157 | Consumer parse(String value) { 158 | return it -> { 159 | String[] numbers = value.split("~"); 160 | it.endNumber = Integer.parseInt(numbers[1]); 161 | it.rangeType = this; 162 | }; 163 | } 164 | }, 165 | 166 | SOME { 167 | @Override 168 | boolean supports(String value) { 169 | return value.matches("\\d+~\\d+"); 170 | } 171 | 172 | @Override 173 | Consumer parse(String value) { 174 | return it -> { 175 | String[] numbers = value.split("~"); 176 | int startNumber = Integer.parseInt(numbers[0]); 177 | int endNumber = Integer.parseInt(numbers[1]); 178 | 179 | if (startNumber == endNumber) { 180 | it.startNumber = startNumber; 181 | it.endNumber = startNumber; 182 | it.rangeType = ONE; 183 | return; 184 | } 185 | 186 | it.startNumber = startNumber; 187 | it.endNumber = endNumber; 188 | it.rangeType = this; 189 | }; 190 | } 191 | }; 192 | 193 | abstract boolean supports(String value); 194 | 195 | abstract Consumer parse(String value); 196 | } 197 | 198 | } 199 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | 3 | - [v3.1.0](#v310): 2023-05-31 4 | - [v3.0.5](#v305): 2023-04-25 5 | - [v3.0.4](#v304): 2023-04-24 6 | - [v3.0.3](#v303): 2023-01-03 7 | - [v3.0.2](#v302): 2022-12-18 8 | - [v3.0.1](#v301): 2022-12-16 9 | - [v3.0.0](#v300): 2022-12-14 10 | - [v2.9.0](#v290): 2022-02-20 11 | 12 | # v3.1.0 13 | 14 | ## Modification 15 | 16 | - ♻️ Make: `WebBrowser` testable without chrome driver 17 | - ♻️ Modify: parsing optional value 18 | 19 | ## New features 20 | 21 | - ✨ Add: new option `--single-threading` 22 | 23 | ## Dependencies 24 | 25 | - ⬆️ Upgrade: `maven` from `3.6.3` to `3.9.2` 26 | - ⬆️ Upgrade: `maven wrapper` from `0.5.6` to `3.2.0` 27 | - ⬆️ Upgrade: dependency `selenium-java` from `4.9.0` to `4.9.1` 28 | - ⬆️ Upgrade: dependency `lombok` from `1.18.26` to `1.18.28` 29 | - ⬆️ Upgrade: test dependency `junit5` from `5.9.2` to `5.9.3` 30 | - ⬆️ Upgrade: build dependency `gmavenplus-plugin` from `2.1.0` to `3.0.0` 31 | - ⬆️ Upgrade: build dependency `maven-surefire-plugin` from `2.22.2` to `3.1.0` 32 | - ⬆️ Upgrade: build dependency `maven-assembly-plugin` from `3.3.0` to `3.5.0` 33 | - ⬆️ Upgrade: build dependency `jacoco-maven-plugin` from `0.8.9` to `0.8.10` 34 | 35 | ## Troubleshooting 36 | 37 | - 🐞 Fix: unused format arguments on exception 38 | - 🐞 Fix: missing images on downloading caused by unknown reason 39 | 40 | # v3.0.5 41 | 42 | ## Dependencies 43 | 44 | - ♻️ Replace: build dependency `maven-assembly-plugin` with `maven-shade-plugin` 45 | 46 | ## Troubleshooting 47 | 48 | - 🐞 Fix: IllegalArgumentException ... Unknown HttpClient factory jdk-http-client 49 | 50 | # v3.0.4 51 | 52 | ## Dependencies 53 | 54 | - ⬆️ Upgrade: dependency `selenium-java` from `4.6.0` to `4.9.0` 55 | - ⬆️ Upgrade: dependency `lombok` from `1.18.24` to `1.18.26` 56 | - ⬆️ Upgrade: dependency `logback-classic` from `1.4.5` to `1.4.7` 57 | - ⬆️ Upgrade: dependency `slf4j-api` from `2.0.6` to `2.0.7` 58 | - ⬆️ Upgrade: dependency `annotations` from `23.1.0` to `24.0.1` 59 | - ⬆️ Upgrade: dependency `progressbar` from `0.9.4` to `0.9.5` 60 | - ⬆️ Upgrade: dependency `mapstruct-plugin` from `1.5.3.Final` to `1.5.5.Final` 61 | - ⬆️ Upgrade: test dependency `junit5` from `5.9.1` to `5.9.2` 62 | - ⬆️ Upgrade: test dependency `assertj-core` from `3.23.1` to `3.24.2` 63 | - ⬆️ Upgrade: test dependency `mockito-inline` from `4.11.0` to `5.2.0` 64 | - ⬆️ Upgrade: build dependency `maven-compiler-plugin` from `3.10.1` to `3.11.0` 65 | - ⬆️ Upgrade: build dependency `maven-assembly-plugin` from `3.3.0` to `3.5.0` 66 | - ⬆️ Upgrade: build dependency `jacoco-maven-plugin` from `0.8.8` to `0.8.9` 67 | 68 | ## Troubleshooting 69 | 70 | - 🐞 Fix: IOException ... Invalid Status code=403 text=Forbidden 71 | - 🐞 Fix: SessionNotCreatedException ... Could not start a new session. Response code 500 72 | 73 | # v3.0.3 74 | 75 | ## Dependencies 76 | 77 | - ⬆️ Upgrade: dependency `common-utils` from `0.13.0` to `0.14.0` 78 | - ⬆️ Upgrade: dependency `annotations` from `23.0.0` to `23.1.0` 79 | - ⬆️ Upgrade: dependency `slf4j-api` from `2.0.4` to `2.0.6` 80 | - ⬆️ Upgrade: dependency `logback-classic` from `1.3.5` to `1.4.5` 81 | - ⬆️ Upgrade: test dependency `mockito-inline` from `4.10.0` to `4.11.0` 82 | - ⬆️ Upgrade: build dependency `gmavenplus-plugin` from `1.13.1` to `2.1.0` 83 | 84 | ## Troubleshooting 85 | 86 | - 🐞 Fix: termination of application 87 | - 🐞 Fix: failure to get image count on locale KOREA 88 | 89 | # v3.0.2 90 | 91 | ## Dependencies 92 | 93 | - ➕ Add: test dependency `mockito-inline` 94 | 95 | ## Troubleshooting 96 | 97 | - 🐞 Fix: creation of directory containing invalid character 98 | 99 | # v3.0.1 100 | 101 | ## Dependencies 102 | 103 | - ⬆️ Upgrade: dependency `slf4j-api` from `1.7.32` to `2.0.4` 104 | 105 | ## Troubleshooting 106 | 107 | - 🐞 Fix: downloading image of lower resolution than manually 108 | - 🐞 Fix: conflict dependency version of logging 109 | 110 | # v3.0.0 111 | 112 | ## Modification 113 | 114 | - ♻️ Refactor: entire application architecture 115 | - ♻️ Change: base package from `io.github.imsejin.lzcodl` to `io.github.imsejin.dl.lezhin` 116 | - ⬆️ Upgrade: java version from `8` to `11` 117 | - 🔥 Remove: model class `Arguments` 118 | - 🔥 Remove: common classes `CommandParser`, `URLFactory`, `UsagePrinter` 119 | - 🔥 Remove: core classes `Crawler`, `Downloader`, `LoginHelper` 120 | 121 | ## Dependencies 122 | 123 | - ⬆️ Upgrade: dependency `progressbar` from `0.9.3` to `0.9.4` 124 | - ⬆️ Upgrade: dependency `common-utils` from `0.7.1` to `0.13.0` 125 | - ⬆️ Upgrade: dependency `lombok` from `1.18.22` to `1.18.24` 126 | - ⬆️ Upgrade: dependency `selenium-java` from `4.1.2` to `4.6.0` 127 | - ⬆️ Upgrade: dependency `logback-classic` from `1.2.10` to `1.3.5` 128 | - ⬆️ Upgrade: test dependency `junit5` from `5.8.2` to `5.9.1` 129 | - ⬆️ Upgrade: test dependency `assertj-core` from `3.22.0` to `3.23.1` 130 | 131 | # v2.9.0 132 | 133 | ## Modification 134 | 135 | - ⚡️ Improve: parsing `application.properties` 136 | - ⚡️ Improve: validation using assertion 137 | - ♻️ Change: type of `Arguments#language` from `String` to `Language` 138 | - 🚚 Rename: method `of(String)` to `from(String)` in `EpisodeRange` 139 | - 🔧 Update: build script 140 | 141 | ## New features 142 | 143 | - ⚡️ Add: chrome driver options 144 | - ✨ Add: method `getVersion()` in `ChromeBrowser` 145 | - 👷 Add: github actions CI 146 | 147 | ## Dependencies 148 | 149 | - ⬆️ Upgrade: dependency `progressbar` from `0.9.2` to `0.9.3` 150 | - ⬆️ Upgrade: dependency `common-utils` from `0.7.0` to `0.7.1` 151 | - ⬆️ Upgrade: dependency `lombok` from `1.18.20` to `1.18.22` 152 | - ⬆️ Upgrade: dependency `selenium-java` from `3.141.59` to `4.1.2` 153 | - ⬆️ Upgrade: dependency `commons-cli` from `1.4` to `1.5.0` 154 | - ⬆️ Upgrade: dependency `slf4j-api` from `1.7.31` to `1.7.36` 155 | - ⬆️ Upgrade: dependency `logback-classic` from `1.2.3` to `1.2.10` 156 | - ⬆️ Upgrade: test dependency `junit5` from `5.7.2` to `5.8.2` 157 | - ⬆️ Upgrade: test dependency `assertj-core` from `3.20.2` to `3.22.0` 158 | - ⬆️ Upgrade: build dependency `maven-assembly-plugin` from `2.6` to `3.3.0` 159 | - ➖ Remove: useless build dependency `maven-dependency-plugin` 160 | 161 | ## Troubleshooting 162 | 163 | - 🐞 Fix: not found hostname(`cdn.lezhin.com` -> `ccdn.lezhin.com`) 164 | - 🐞 Fix: `301 Moved Permanently` with changing to http/s protocol 165 | 166 | ```html 167 | 168 | 301 Moved Permanently 169 | 170 |

301 Moved Permanently

171 |
CloudFront
172 | 173 | 174 | ``` 175 | 176 | - 🐞 Fix: mis-computation of parsing `EpisodeRange` 177 | - 🐞 Fix: invocation `Object#equals(Object)` comparing incomparable types `Enum` and `String` 178 | -------------------------------------------------------------------------------- /src/test/resources/json/ko-christmas_in_the_elevator.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 5943773066559488, 3 | "display": { 4 | "title": "엘리베이터에서 하룻밤", 5 | "schedule": "완결", 6 | "synopsis": "계약 만료를 앞둔 계약직 말단 사원 이도는 크리스마스 이브 날 잡힌 회식이 괜시리 쓸쓸하기만 하다. 하지만 크리스마스에 눈이 온다면, 그리고 사랑이 이루어진다면...? 세상에 수많은 if가 있지만 이보다 더 로맨틱할 순 없다. 김지효 작가가 그리는 뜻밖의 크리스마스 러브 스토리 <엘리베이터에서 하룻밤>", 7 | "notice": "" 8 | }, 9 | "properties": { 10 | "days": [ 11 | "1" 12 | ], 13 | "hasSide": false, 14 | "hasBgm": false, 15 | "expired": false, 16 | "notForSale": false, 17 | "coin": 3, 18 | "point": 0, 19 | "tags": [ 20 | "썸", 21 | "상사", 22 | "드라마", 23 | "로맨스" 24 | ], 25 | "tagProperties": [ 26 | { 27 | "tagId": 2146, 28 | "divisionId": 6, 29 | "genreId": 0, 30 | "type": "normal", 31 | "dictionary": true, 32 | "adult": false, 33 | "enabled": true, 34 | "tag": "썸", 35 | "name": "썸", 36 | "lang": "KO" 37 | }, 38 | { 39 | "tagId": 2310, 40 | "divisionId": 6, 41 | "genreId": 0, 42 | "type": "normal", 43 | "dictionary": true, 44 | "adult": false, 45 | "enabled": true, 46 | "tag": "상사", 47 | "name": "상사", 48 | "lang": "KO" 49 | }, 50 | { 51 | "tagId": 1171, 52 | "divisionId": 1, 53 | "genreId": 28, 54 | "type": "meta", 55 | "dictionary": true, 56 | "adult": false, 57 | "enabled": true, 58 | "tag": "drama", 59 | "name": "드라마", 60 | "lang": "KO" 61 | }, 62 | { 63 | "tagId": 1192, 64 | "divisionId": 1, 65 | "genreId": 58, 66 | "type": "meta", 67 | "dictionary": true, 68 | "adult": false, 69 | "enabled": true, 70 | "tag": "romance", 71 | "name": "로맨스", 72 | "lang": "KO" 73 | } 74 | ], 75 | "isPrint": false, 76 | "isCrossView": false, 77 | "isVeiled": false 78 | }, 79 | "artists": [ 80 | { 81 | "id": "kim_ji_hyo", 82 | "name": "김지효", 83 | "role": "writer", 84 | "email": null 85 | } 86 | ], 87 | "badge": "", 88 | "firstEpisodeId": 5668435598114816, 89 | "lastEpisodeId": 5156581780094976, 90 | "publishedEpisodeSize": 5, 91 | "freedEpisodeSize": 2, 92 | "contentType": "comic", 93 | "lastEpisodePublishedAt": 1514095200000, 94 | "publishedAt": 1512313200000, 95 | "newPublishedAt": 1512313200000, 96 | "updatedAt": 1651553929740, 97 | "alias": "christmas_in_the_elevator", 98 | "state": "completed", 99 | "imageCounts": { 100 | "banners": 0 101 | }, 102 | "related": [ 103 | { 104 | "id": 4907714540535808, 105 | "type": "comic", 106 | "title": "안나 이야기", 107 | "alias": "story_of_anna", 108 | "rating": 1, 109 | "genres": [ 110 | { 111 | "id": "romance", 112 | "name": "로맨스" 113 | } 114 | ], 115 | "badge": "", 116 | "artists": [ 117 | { 118 | "id": "kim_ji_hyo", 119 | "name": "김지효", 120 | "role": "writer", 121 | "email": null 122 | } 123 | ], 124 | "updatedAt": 1661050242781, 125 | "webp": "https://ccdn.lezhin.com/v2/comics/4907714540535808/images/tall.webp?updated=1661050242781&width=240", 126 | "jpg": "https://ccdn.lezhin.com/v2/comics/4907714540535808/images/tall.jpg?updated=1661050242781&width=240", 127 | "lang": "ko", 128 | "url": "/ko/comic/story_of_anna" 129 | }, 130 | { 131 | "id": 5320790438576128, 132 | "type": "comic", 133 | "title": "생쥐와 소녀", 134 | "alias": "mouse_and_the_girl", 135 | "rating": 15, 136 | "genres": [ 137 | { 138 | "id": "fantasy", 139 | "name": "판타지" 140 | } 141 | ], 142 | "badge": "e", 143 | "artists": [ 144 | { 145 | "id": "kim_ji_hyo", 146 | "name": "김지효", 147 | "role": "writer", 148 | "email": null 149 | } 150 | ], 151 | "updatedAt": 1651553862912, 152 | "webp": "https://ccdn.lezhin.com/v2/comics/5320790438576128/images/tall.webp?updated=1651553862912&width=240", 153 | "jpg": "https://ccdn.lezhin.com/v2/comics/5320790438576128/images/tall.jpg?updated=1651553862912&width=240", 154 | "lang": "ko", 155 | "url": "/ko/comic/mouse_and_the_girl" 156 | }, 157 | { 158 | "id": 5664587191156736, 159 | "type": "comic", 160 | "title": "기생충", 161 | "alias": "parasite", 162 | "rating": 15, 163 | "genres": [ 164 | { 165 | "id": "romance", 166 | "name": "로맨스" 167 | } 168 | ], 169 | "badge": "", 170 | "artists": [ 171 | { 172 | "id": "kim_ji_hyo", 173 | "name": "김지효", 174 | "role": "writer", 175 | "email": null 176 | } 177 | ], 178 | "updatedAt": 1651545710702, 179 | "webp": "https://ccdn.lezhin.com/v2/comics/5664587191156736/images/tall.webp?updated=1651545710702&width=240", 180 | "jpg": "https://ccdn.lezhin.com/v2/comics/5664587191156736/images/tall.jpg?updated=1651545710702&width=240", 181 | "lang": "ko", 182 | "url": "/ko/comic/parasite" 183 | } 184 | ], 185 | "genres": [ 186 | "romance" 187 | ], 188 | "rating": "15", 189 | "ratings": { 190 | "_z_base": 1, 191 | "jp_base": 1, 192 | "kr_base": 15 193 | }, 194 | "completedAt": 1519225200000, 195 | "locale": "ko-KR", 196 | "isAdult": false, 197 | "episodes": [ 198 | { 199 | "id": 5156581780094976, 200 | "name": "e1", 201 | "display": { 202 | "title": "에필로그", 203 | "type": "e", 204 | "displayName": "에필로그" 205 | }, 206 | "properties": { 207 | "expired": false, 208 | "notForSale": false 209 | }, 210 | "coin": 0, 211 | "freedAt": 1514127600000, 212 | "openedAt": 1514127600000, 213 | "publishedAt": 1514127600000, 214 | "updatedAt": 1512042698558, 215 | "seq": 5 216 | }, 217 | { 218 | "id": 5313384744615936, 219 | "name": "3", 220 | "display": { 221 | "title": "메리크리스마스", 222 | "type": "g", 223 | "displayName": "03" 224 | }, 225 | "properties": { 226 | "expired": false, 227 | "notForSale": false 228 | }, 229 | "coin": 3, 230 | "publishedAt": 1513522800000, 231 | "updatedAt": 1512042687123, 232 | "seq": 4 233 | }, 234 | { 235 | "id": 5176088900796416, 236 | "name": "2", 237 | "display": { 238 | "title": "전야제", 239 | "type": "g", 240 | "displayName": "02" 241 | }, 242 | "properties": { 243 | "expired": false, 244 | "notForSale": false 245 | }, 246 | "coin": 3, 247 | "publishedAt": 1512918000000, 248 | "updatedAt": 1512042678946, 249 | "seq": 3 250 | }, 251 | { 252 | "id": 5396364351635456, 253 | "name": "1", 254 | "display": { 255 | "title": "회식", 256 | "type": "g", 257 | "displayName": "01" 258 | }, 259 | "properties": { 260 | "expired": false, 261 | "notForSale": false 262 | }, 263 | "coin": 3, 264 | "publishedAt": 1512313200000, 265 | "updatedAt": 1512285305534, 266 | "seq": 2 267 | }, 268 | { 269 | "id": 5668435598114816, 270 | "name": "p1", 271 | "display": { 272 | "title": "프롤로그", 273 | "type": "p", 274 | "displayName": "프롤로그" 275 | }, 276 | "properties": { 277 | "expired": false, 278 | "notForSale": false 279 | }, 280 | "coin": 0, 281 | "freedAt": 1512313200000, 282 | "openedAt": 1512313200000, 283 | "publishedAt": 1512313200000, 284 | "updatedAt": 1512285078479, 285 | "seq": 1 286 | } 287 | ], 288 | "notification": false 289 | } 290 | --------------------------------------------------------------------------------