Compare commits

...

5 Commits

Author SHA1 Message Date
247788c3d0 feat(scrapper): use flare-solverr instead of custom web scrapper
Some checks failed
ci/woodpecker/push/pipeline Pipeline was successful
ci/woodpecker/pr/pipeline Pipeline failed
2025-11-15 19:16:47 -03:00
rov
1aa6a39c96 Merge pull request 'feat: update image selection logic in BatoProvider for chapter pages' (#20) from feature/bato into main
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
Reviewed-on: #20
2025-11-14 08:32:30 -03:00
08ad47a875 feat: update image selection logic in BatoProvider for chapter pages
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
ci/woodpecker/pr/pipeline Pipeline was successful
2025-11-14 08:22:48 -03:00
rov
42f0b9ec4d Merge pull request 'feat: implement Bato content provider for manga import and chapter fetching' (#19) from feature/bato into main
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
Reviewed-on: #19
2025-11-13 23:12:29 -03:00
3f3a3739b6 feat: implement Bato content provider for manga import and chapter fetching
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
ci/woodpecker/pr/pipeline Pipeline was successful
2025-11-13 23:03:32 -03:00
23 changed files with 510 additions and 38 deletions

1
.env
View File

@ -6,6 +6,7 @@ MINIO_ENDPOINT=http://omv2.badger-pirarucu.ts.net:9000
MINIO_USER=rov MINIO_USER=rov
MINIO_PASS=!E9v4i0v3 MINIO_PASS=!E9v4i0v3
FLARESOLVERR_ENDPOINT=https://flare-solverr.badger-pirarucu.ts.net
WEBSCRAPPER_ENDPOINT=http://mangamochi.badger-pirarucu.ts.net:8090/url WEBSCRAPPER_ENDPOINT=http://mangamochi.badger-pirarucu.ts.net:8090/url
MANGAMATCHER_ENDPOINT=http://mangamochi.badger-pirarucu.ts.net:8000/match-title MANGAMATCHER_ENDPOINT=http://mangamochi.badger-pirarucu.ts.net:8000/match-title

View File

@ -0,0 +1,73 @@
package com.magamochi.mangamochi.client;
import io.github.resilience4j.retry.annotation.Retry;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@FeignClient(name = "flare-solverr", url = "${flare-solverr.endpoint}/v1")
@Retry(name = "FlareSolverrRetry")
public interface FlareClient {
@PostMapping(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
GetResponse get(@RequestBody GetRequest request);
@PostMapping(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
SessionCreateResponse createSession(@RequestBody SessionCreateRequest request);
@PostMapping(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
SessionDestroyResponse destroySession(@RequestBody SessionDestroyRequest request);
@PostMapping(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
SessionListResponse listSessions(@RequestBody SessionListRequest request);
@Getter
@Builder
class GetRequest {
@Builder.Default private final String cmd = "request.get";
@Builder.Default private final Integer maxTimeout = 120000;
private final String url;
private final String session;
}
@Getter
@Builder
class SessionCreateRequest {
@Builder.Default private final String cmd = "sessions.create";
}
@Getter
@Builder
class SessionDestroyRequest {
@Builder.Default private final String cmd = "sessions.destroy";
private final String session;
}
@Getter
@Builder
class SessionListRequest {
@Builder.Default private final String cmd = "sessions.list";
}
record GetResponse(String status, String message, Solution solution) {
public record Solution(String url, Integer status, String response) {}
}
record SessionCreateResponse(String status, String message, String session) {}
record SessionDestroyResponse(String status, String message) {}
record SessionListResponse(String status, String message, List<String> sessions) {}
}

View File

@ -33,6 +33,18 @@ public class ManagementController {
return DefaultResponseDTO.ok().build(); return DefaultResponseDTO.ok().build();
} }
@Operation(
summary = "Queue update provider manga list",
description = "Queue the retrieval of the manga list for a specific provider",
tags = {"Management"},
operationId = "updateProviderMangaList")
@PostMapping("update-provider-manga-list")
public DefaultResponseDTO<Void> updateProviderMangaList(@RequestParam Long providerId) {
updateMangaListTask.updateProviderMangaList(providerId);
return DefaultResponseDTO.ok().build();
}
@Operation( @Operation(
summary = "Cleanup unused S3 images", summary = "Cleanup unused S3 images",
description = "Triggers the cleanup of untracked S3 images", description = "Triggers the cleanup of untracked S3 images",

View File

@ -2,6 +2,7 @@ package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.*; import com.magamochi.mangamochi.model.dto.*;
import com.magamochi.mangamochi.service.MangaImportService; import com.magamochi.mangamochi.service.MangaImportService;
import com.magamochi.mangamochi.service.providers.impl.BatoProvider;
import com.magamochi.mangamochi.service.providers.impl.MangaDexProvider; import com.magamochi.mangamochi.service.providers.impl.MangaDexProvider;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@ -19,6 +20,7 @@ import org.springframework.web.multipart.MultipartFile;
@RequiredArgsConstructor @RequiredArgsConstructor
public class MangaImportController { public class MangaImportController {
private final MangaDexProvider mangaDexProvider; private final MangaDexProvider mangaDexProvider;
private final BatoProvider batoProvider;
private final MangaImportService mangaImportService; private final MangaImportService mangaImportService;
@Operation( @Operation(
@ -27,11 +29,22 @@ public class MangaImportController {
tags = {"Manga Import"}, tags = {"Manga Import"},
operationId = "importFromMangaDex") operationId = "importFromMangaDex")
@PostMapping("/manga-dex") @PostMapping("/manga-dex")
public DefaultResponseDTO<ImportMangaDexResponseDTO> importFromMangaDex( public DefaultResponseDTO<ImportMangaResponseDTO> importFromMangaDex(
@RequestBody ImportMangaDexRequestDTO requestDTO) { @RequestBody ImportRequestDTO requestDTO) {
return DefaultResponseDTO.ok(mangaDexProvider.importManga(requestDTO.id())); return DefaultResponseDTO.ok(mangaDexProvider.importManga(requestDTO.id()));
} }
@Operation(
summary = "Import manga from Bato",
description = "Imports manga data from Bato into the local database.",
tags = {"Manga Import"},
operationId = "importFromBato")
@PostMapping("/bato")
public DefaultResponseDTO<ImportMangaResponseDTO> importFromBato(
@RequestBody ImportRequestDTO requestDTO) {
return DefaultResponseDTO.ok(batoProvider.importManga(requestDTO.id()));
}
@Operation( @Operation(
summary = "Upload multiple files", summary = "Upload multiple files",
description = "Accepts multiple files via multipart/form-data and processes them.", description = "Accepts multiple files via multipart/form-data and processes them.",

View File

@ -2,4 +2,4 @@ package com.magamochi.mangamochi.model.dto;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
public record ImportMangaDexResponseDTO(@NotNull Long id) {} public record ImportMangaResponseDTO(@NotNull Long id) {}

View File

@ -1,6 +1,5 @@
package com.magamochi.mangamochi.model.dto; package com.magamochi.mangamochi.model.dto;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.util.UUID;
public record ImportMangaDexRequestDTO(@NotNull UUID id) {} public record ImportRequestDTO(@NotNull String id) {}

View File

@ -0,0 +1,11 @@
package com.magamochi.mangamochi.model.entity;
import java.time.Instant;
import lombok.Builder;
@Builder
public record FlareSession(String sessionId, Instant lastAccess) {
public FlareSession updateLastAccess() {
return new FlareSession(this.sessionId, Instant.now());
}
}

View File

@ -57,7 +57,7 @@ public class Manga {
@OneToMany(mappedBy = "manga") @OneToMany(mappedBy = "manga")
private List<MangaAlternativeTitle> alternativeTitles; private List<MangaAlternativeTitle> alternativeTitles;
private Integer chapterCount; @Builder.Default private Integer chapterCount = 0;
private Boolean follow; @Builder.Default private Boolean follow = false;
} }

View File

@ -32,5 +32,5 @@ public class Provider {
@OneToMany(mappedBy = "provider") @OneToMany(mappedBy = "provider")
private List<MangaProvider> mangaProviders; private List<MangaProvider> mangaProviders;
private Boolean supportsChapterFetch; @Builder.Default private Boolean supportsChapterFetch = true;
} }

View File

@ -0,0 +1,24 @@
package com.magamochi.mangamochi.registry;
import com.magamochi.mangamochi.model.entity.FlareSession;
import java.util.concurrent.ConcurrentHashMap;
import lombok.Getter;
import org.springframework.stereotype.Component;
@Component
@Getter
public class FlareSessionRegistry {
private final ConcurrentHashMap<String, FlareSession> sessions = new ConcurrentHashMap<>();
public FlareSession get(String provider) {
return sessions.get(provider);
}
public void put(String provider, FlareSession session) {
sessions.put(provider, session);
}
public void remove(String provider) {
sessions.remove(provider);
}
}

View File

@ -0,0 +1,27 @@
package com.magamochi.mangamochi.service;
import com.magamochi.mangamochi.client.FlareClient;
import lombok.RequiredArgsConstructor;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class FlareService {
private final FlareClient client;
private final FlareSessionManager sessionManager;
public Document getContentAsJsoupDocument(String url, String providerName) {
return Jsoup.parse(getContent(url, providerName));
}
private String getContent(String url, String providerName) {
var session = sessionManager.getOrCreateSession(providerName);
return client
.get(FlareClient.GetRequest.builder().url(url).session(session).build())
.solution()
.response();
}
}

View File

@ -0,0 +1,60 @@
package com.magamochi.mangamochi.service;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import com.magamochi.mangamochi.client.FlareClient;
import com.magamochi.mangamochi.model.entity.FlareSession;
import com.magamochi.mangamochi.registry.FlareSessionRegistry;
import java.time.Duration;
import java.time.Instant;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class FlareSessionManager {
private static final Duration TIMEOUT = Duration.ofMinutes(15);
private final FlareClient flareClient;
private final FlareSessionRegistry registry;
public String getOrCreateSession(String provider) {
log.info("Getting session for {}", provider);
var session = registry.get(provider);
if (isNull(session) || isExpired(session)) {
if (nonNull(session)) {
log.info("Session expired for {}", provider);
flareClient.destroySession(
FlareClient.SessionDestroyRequest.builder().session(session.sessionId()).build());
registry.remove(provider);
} else {
log.info("Session not found for {}", provider);
}
log.info("Creating session for {}", provider);
var newId =
flareClient.createSession(FlareClient.SessionCreateRequest.builder().build()).session();
session = new FlareSession(newId, Instant.now());
registry.put(provider, session);
} else {
log.info("Got session for {}", provider);
}
registry.put(provider, session.updateLastAccess());
return session.sessionId();
}
private boolean isExpired(FlareSession session) {
return Duration.between(session.lastAccess(), Instant.now()).compareTo(TIMEOUT) > 0;
}
}

View File

@ -6,12 +6,14 @@ import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.mangamochi.client.JikanClient; import com.magamochi.mangamochi.client.JikanClient;
import com.magamochi.mangamochi.exception.NotFoundException; import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.model.dto.ImportReviewDTO; import com.magamochi.mangamochi.model.dto.ImportReviewDTO;
import com.magamochi.mangamochi.model.dto.UpdateMangaDataCommand;
import com.magamochi.mangamochi.model.entity.Manga; import com.magamochi.mangamochi.model.entity.Manga;
import com.magamochi.mangamochi.model.entity.MangaImportReview; import com.magamochi.mangamochi.model.entity.MangaImportReview;
import com.magamochi.mangamochi.model.entity.MangaProvider; import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.repository.MangaImportReviewRepository; import com.magamochi.mangamochi.model.repository.MangaImportReviewRepository;
import com.magamochi.mangamochi.model.repository.MangaProviderRepository; import com.magamochi.mangamochi.model.repository.MangaProviderRepository;
import com.magamochi.mangamochi.model.repository.MangaRepository; import com.magamochi.mangamochi.model.repository.MangaRepository;
import com.magamochi.mangamochi.queue.UpdateMangaDataProducer;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -27,6 +29,8 @@ public class MangaImportReviewService {
private final RateLimiter jikanRateLimiter; private final RateLimiter jikanRateLimiter;
private final UpdateMangaDataProducer updateMangaDataProducer;
public List<ImportReviewDTO> getImportReviews() { public List<ImportReviewDTO> getImportReviews() {
return mangaImportReviewRepository.findAll().stream().map(ImportReviewDTO::from).toList(); return mangaImportReviewRepository.findAll().stream().map(ImportReviewDTO::from).toList();
} }
@ -67,6 +71,8 @@ public class MangaImportReviewService {
.build()); .build());
mangaImportReviewRepository.delete(importReview); mangaImportReviewRepository.delete(importReview);
updateMangaDataProducer.sendUpdateMangaDataCommand(new UpdateMangaDataCommand(manga.getId()));
} }
private MangaImportReview getImportReviewThrowIfNotFound(Long id) { private MangaImportReview getImportReviewThrowIfNotFound(Long id) {

View File

@ -5,4 +5,5 @@ public class ContentProviders {
public static final String MANGA_LIVRE_BLOG = "Manga Livre Blog"; public static final String MANGA_LIVRE_BLOG = "Manga Livre Blog";
public static final String MANGA_DEX = "MangaDex"; public static final String MANGA_DEX = "MangaDex";
public static final String PINK_ROSA_SCAN = "Pink Rosa Scan"; public static final String PINK_ROSA_SCAN = "Pink Rosa Scan";
public static final String BATO = "Bato";
} }

View File

@ -0,0 +1,126 @@
package com.magamochi.mangamochi.service.providers.impl;
import static java.util.Objects.isNull;
import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.exception.UnprocessableException;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.mangamochi.model.dto.ImportMangaResponseDTO;
import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.entity.Provider;
import com.magamochi.mangamochi.model.enumeration.ProviderStatus;
import com.magamochi.mangamochi.model.repository.MangaProviderRepository;
import com.magamochi.mangamochi.model.repository.ProviderRepository;
import com.magamochi.mangamochi.service.FlareService;
import com.magamochi.mangamochi.service.MangaCreationService;
import com.magamochi.mangamochi.service.providers.ContentProvider;
import com.magamochi.mangamochi.service.providers.ContentProviders;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
@Log4j2
@Service(ContentProviders.BATO)
@RequiredArgsConstructor
public class BatoProvider implements ContentProvider {
private static final String URL = "https://battwo.com";
private final FlareService flareService;
private final MangaCreationService mangaCreationService;
private final ProviderRepository providerRepository;
private final MangaProviderRepository mangaProviderRepository;
@Override
public List<ContentProviderMangaChapterResponseDTO> getAvailableChapters(MangaProvider provider) {
try {
var document =
flareService.getContentAsJsoupDocument(provider.getUrl(), ContentProviders.BATO);
// Direct selector for chapter links
var chapterLinks = document.select("div.scrollable-panel a[href*=/title/]");
return chapterLinks.stream()
.map(
chapterLink ->
new ContentProviderMangaChapterResponseDTO(
chapterLink.text(), chapterLink.attr("href"), null, null))
.toList();
} catch (Exception e) {
log.warn(e.getMessage());
return null;
}
}
@Override
public Map<Integer, String> getChapterImagesUrls(String chapterUrl) {
try {
var document =
flareService.getContentAsJsoupDocument(
URL + chapterUrl + "?load=2", ContentProviders.BATO);
// Select all chapter page images
var imgElements = document.select("img.z-10.w-full.h-full");
List<String> imageUrls = new ArrayList<>();
for (var img : imgElements) {
String src = img.attr("src");
// Normalize if needed
if (!src.startsWith("http")) {
src = "https://battwo.com" + src;
}
imageUrls.add(src);
}
return IntStream.range(0, imageUrls.size())
.boxed()
.collect(
Collectors.toMap(
i -> i, imageUrls::get, (existing, replacement) -> existing, LinkedHashMap::new));
} catch (Exception e) {
log.warn(e.getMessage());
return null;
}
}
public ImportMangaResponseDTO importManga(String url) {
var document = flareService.getContentAsJsoupDocument(url, ContentProviders.BATO);
// Method 1: Look for the main title in the manga info section
var titleElement = document.selectFirst("h3 a[href*=/title/]");
if (isNull(titleElement)) {
throw new UnprocessableException("Manga title not found for url: " + url);
}
var mangaTitle = titleElement.text();
var provider =
providerRepository
.findByNameIgnoreCase("Bato")
.orElseGet(
() ->
providerRepository.save(
Provider.builder().name("Bato").status(ProviderStatus.ACTIVE).build()));
var manga = mangaCreationService.getOrCreateManga(mangaTitle, url, provider);
if (isNull(manga)) {
throw new NotFoundException("Manga could not be found or created for url: " + url);
}
mangaProviderRepository.save(
MangaProvider.builder()
.manga(manga)
.mangaTitle(mangaTitle)
.provider(provider)
.url(url)
.build());
return new ImportMangaResponseDTO(manga.getId());
}
}

View File

@ -7,7 +7,7 @@ import com.magamochi.mangamochi.client.MangaDexClient;
import com.magamochi.mangamochi.exception.NotFoundException; import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.exception.UnprocessableException; import com.magamochi.mangamochi.exception.UnprocessableException;
import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO; import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.mangamochi.model.dto.ImportMangaDexResponseDTO; import com.magamochi.mangamochi.model.dto.ImportMangaResponseDTO;
import com.magamochi.mangamochi.model.entity.MangaProvider; import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.entity.Provider; import com.magamochi.mangamochi.model.entity.Provider;
import com.magamochi.mangamochi.model.enumeration.ProviderStatus; import com.magamochi.mangamochi.model.enumeration.ProviderStatus;
@ -112,9 +112,9 @@ public class MangaDexProvider implements ContentProvider {
LinkedHashMap::new)); LinkedHashMap::new));
} }
public ImportMangaDexResponseDTO importManga(UUID id) { public ImportMangaResponseDTO importManga(String id) {
mangaDexRateLimiter.acquire(); mangaDexRateLimiter.acquire();
var resultData = mangaDexClient.getManga(id).data(); var resultData = mangaDexClient.getManga(UUID.fromString(id)).data();
if (resultData.attributes().title().isEmpty()) { if (resultData.attributes().title().isEmpty()) {
throw new UnprocessableException("Manga title not found for ID: " + id); throw new UnprocessableException("Manga title not found for ID: " + id);
@ -152,6 +152,6 @@ public class MangaDexProvider implements ContentProvider {
.url(id.toString()) .url(id.toString())
.build()); .build());
return new ImportMangaDexResponseDTO(manga.getId()); return new ImportMangaResponseDTO(manga.getId());
} }
} }

View File

@ -32,6 +32,11 @@ public class MangaLivreBlogProvider implements ContentProvider, PagedContentProv
@Override @Override
public List<ContentProviderMangaChapterResponseDTO> getAvailableChapters( public List<ContentProviderMangaChapterResponseDTO> getAvailableChapters(
MangaProvider mangaProvider) { MangaProvider mangaProvider) {
log.info(
"Getting available chapters from {}, manga {}",
ContentProviders.MANGA_LIVRE_BLOG,
mangaProvider.getManga().getTitle());
try { try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(mangaProvider.getUrl()); var document = webScrapperClientProxyService.scrapeToJsoupDocument(mangaProvider.getUrl());
@ -59,6 +64,8 @@ public class MangaLivreBlogProvider implements ContentProvider, PagedContentProv
@Override @Override
public Map<Integer, String> getChapterImagesUrls(String chapterUrl) { public Map<Integer, String> getChapterImagesUrls(String chapterUrl) {
log.info("Getting images from {}, url {}", ContentProviders.MANGA_LIVRE_BLOG, chapterUrl);
try { try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(chapterUrl); var document = webScrapperClientProxyService.scrapeToJsoupDocument(chapterUrl);
@ -97,6 +104,8 @@ public class MangaLivreBlogProvider implements ContentProvider, PagedContentProv
@Override @Override
public List<ContentProviderMangaInfoResponseDTO> getMangasFromPage(Integer page) { public List<ContentProviderMangaInfoResponseDTO> getMangasFromPage(Integer page) {
log.info("Getting mangas from {}, page {}", ContentProviders.MANGA_LIVRE_BLOG, page);
try { try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(url + "page/" + page); var document = webScrapperClientProxyService.scrapeToJsoupDocument(url + "page/" + page);
@ -148,6 +157,8 @@ public class MangaLivreBlogProvider implements ContentProvider, PagedContentProv
@Override @Override
public Integer getTotalPages() { public Integer getTotalPages() {
log.info("Getting total pages for {}", ContentProviders.MANGA_LIVRE_BLOG);
try { try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(url); var document = webScrapperClientProxyService.scrapeToJsoupDocument(url);

View File

@ -4,11 +4,10 @@ import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO
import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO; import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO;
import com.magamochi.mangamochi.model.entity.MangaProvider; import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.enumeration.MangaStatus; import com.magamochi.mangamochi.model.enumeration.MangaStatus;
import com.magamochi.mangamochi.service.WebScrapperClientProxyService; import com.magamochi.mangamochi.service.FlareService;
import com.magamochi.mangamochi.service.providers.ContentProvider; import com.magamochi.mangamochi.service.providers.ContentProvider;
import com.magamochi.mangamochi.service.providers.ContentProviders; import com.magamochi.mangamochi.service.providers.ContentProviders;
import com.magamochi.mangamochi.service.providers.PagedContentProvider; import com.magamochi.mangamochi.service.providers.PagedContentProvider;
import java.io.IOException;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.IntStream; import java.util.stream.IntStream;
@ -22,12 +21,18 @@ import org.springframework.stereotype.Service;
public class MangaLivreProvider implements ContentProvider, PagedContentProvider { public class MangaLivreProvider implements ContentProvider, PagedContentProvider {
private final String url = "https://mangalivre.tv/manga/"; private final String url = "https://mangalivre.tv/manga/";
private final WebScrapperClientProxyService webScrapperClientProxyService; private final FlareService flareService;
@Override @Override
public List<ContentProviderMangaChapterResponseDTO> getAvailableChapters(MangaProvider provider) { public List<ContentProviderMangaChapterResponseDTO> getAvailableChapters(MangaProvider provider) {
log.info(
"Getting available chapters from {}, manga {}",
ContentProviders.MANGA_LIVRE,
provider.getManga().getTitle());
try { try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(provider.getUrl()); var document =
flareService.getContentAsJsoupDocument(provider.getUrl(), ContentProviders.MANGA_LIVRE);
var chapterItems = document.getElementsByClass("wp-manga-chapter"); var chapterItems = document.getElementsByClass("wp-manga-chapter");
@ -40,7 +45,7 @@ public class MangaLivreProvider implements ContentProvider, PagedContentProvider
linkElement.text(), linkElement.attr("href"), null, null); linkElement.text(), linkElement.attr("href"), null, null);
}) })
.toList(); .toList();
} catch (NoSuchElementException | IOException e) { } catch (NoSuchElementException e) {
log.error("Error parsing mangas from MangaLivre", e); log.error("Error parsing mangas from MangaLivre", e);
return List.of(); return List.of();
} }
@ -48,8 +53,11 @@ public class MangaLivreProvider implements ContentProvider, PagedContentProvider
@Override @Override
public Map<Integer, String> getChapterImagesUrls(String chapterUrl) { public Map<Integer, String> getChapterImagesUrls(String chapterUrl) {
log.info("Getting images from {}, url {}", ContentProviders.MANGA_LIVRE, chapterUrl);
try { try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(chapterUrl); var document =
flareService.getContentAsJsoupDocument(chapterUrl, ContentProviders.MANGA_LIVRE);
var chapterImagesContainer = document.getElementsByClass("chapter-images").getFirst(); var chapterImagesContainer = document.getElementsByClass("chapter-images").getFirst();
var chapterImagesElements = chapterImagesContainer.getElementsByClass("page-break"); var chapterImagesElements = chapterImagesContainer.getElementsByClass("page-break");
@ -68,7 +76,7 @@ public class MangaLivreProvider implements ContentProvider, PagedContentProvider
.collect( .collect(
Collectors.toMap( Collectors.toMap(
i -> i, imageUrls::get, (existing, replacement) -> existing, LinkedHashMap::new)); i -> i, imageUrls::get, (existing, replacement) -> existing, LinkedHashMap::new));
} catch (NoSuchElementException | IOException e) { } catch (NoSuchElementException e) {
log.error("Error parsing mangas from MangaLivre", e); log.error("Error parsing mangas from MangaLivre", e);
return Map.of(); return Map.of();
} }
@ -76,8 +84,12 @@ public class MangaLivreProvider implements ContentProvider, PagedContentProvider
@Override @Override
public List<ContentProviderMangaInfoResponseDTO> getMangasFromPage(Integer page) { public List<ContentProviderMangaInfoResponseDTO> getMangasFromPage(Integer page) {
log.info("Getting mangas from {}, page {}", ContentProviders.MANGA_LIVRE, page);
try { try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(url + "page/" + page); var document =
flareService.getContentAsJsoupDocument(
url + "page/" + page, ContentProviders.MANGA_LIVRE);
var mangaElements = document.getElementsByClass("manga__item"); var mangaElements = document.getElementsByClass("manga__item");
@ -115,7 +127,7 @@ public class MangaLivreProvider implements ContentProvider, PagedContentProvider
title, url, imgUrl, MangaStatus.UNKNOWN); title, url, imgUrl, MangaStatus.UNKNOWN);
}) })
.toList(); .toList();
} catch (NoSuchElementException | IOException e) { } catch (NoSuchElementException e) {
log.error("Error parsing mangas from MangaLivre", e); log.error("Error parsing mangas from MangaLivre", e);
return List.of(); return List.of();
} }
@ -123,8 +135,10 @@ public class MangaLivreProvider implements ContentProvider, PagedContentProvider
@Override @Override
public Integer getTotalPages() { public Integer getTotalPages() {
log.info("Getting total pages for {}", ContentProviders.MANGA_LIVRE);
try { try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(url); var document = flareService.getContentAsJsoupDocument(url, ContentProviders.MANGA_LIVRE);
var navLinks = document.getElementsByClass("wp-pagenavi").getFirst(); var navLinks = document.getElementsByClass("wp-pagenavi").getFirst();
var lastPageElement = navLinks.getElementsByClass("last").getFirst(); var lastPageElement = navLinks.getElementsByClass("last").getFirst();
@ -132,7 +146,7 @@ public class MangaLivreProvider implements ContentProvider, PagedContentProvider
var totalPages = links.replaceAll("\\D+", ""); var totalPages = links.replaceAll("\\D+", "");
return Integer.parseInt(totalPages); return Integer.parseInt(totalPages);
} catch (NoSuchElementException | IOException e) { } catch (NoSuchElementException e) {
log.error("Error parsing total pages from MangaLivre", e); log.error("Error parsing total pages from MangaLivre", e);
return null; return null;
} }

View File

@ -7,11 +7,10 @@ import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO
import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO; import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO;
import com.magamochi.mangamochi.model.entity.MangaProvider; import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.enumeration.MangaStatus; import com.magamochi.mangamochi.model.enumeration.MangaStatus;
import com.magamochi.mangamochi.service.WebScrapperClientProxyService; import com.magamochi.mangamochi.service.FlareService;
import com.magamochi.mangamochi.service.providers.ContentProvider; import com.magamochi.mangamochi.service.providers.ContentProvider;
import com.magamochi.mangamochi.service.providers.ContentProviders; import com.magamochi.mangamochi.service.providers.ContentProviders;
import com.magamochi.mangamochi.service.providers.PagedContentProvider; import com.magamochi.mangamochi.service.providers.PagedContentProvider;
import java.io.IOException;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.IntStream; import java.util.stream.IntStream;
@ -23,12 +22,22 @@ import org.springframework.stereotype.Service;
@Service(ContentProviders.PINK_ROSA_SCAN) @Service(ContentProviders.PINK_ROSA_SCAN)
@RequiredArgsConstructor @RequiredArgsConstructor
public class PinkRosaScanProvider implements ContentProvider, PagedContentProvider { public class PinkRosaScanProvider implements ContentProvider, PagedContentProvider {
private final WebScrapperClientProxyService webScrapperClientProxyService; private static final String URL =
"https://scanpinkrosa.blogspot.com/search/label/Series?max-results=1000";
private final FlareService flareService;
@Override @Override
public List<ContentProviderMangaChapterResponseDTO> getAvailableChapters(MangaProvider provider) { public List<ContentProviderMangaChapterResponseDTO> getAvailableChapters(MangaProvider provider) {
log.info(
"Getting available chapters from {}, manga {}",
ContentProviders.PINK_ROSA_SCAN,
provider.getManga().getTitle());
try { try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(provider.getUrl()); var document =
flareService.getContentAsJsoupDocument(
provider.getUrl(), ContentProviders.PINK_ROSA_SCAN);
var chapterList = document.getElementById("chapter-list"); var chapterList = document.getElementById("chapter-list");
if (isNull(chapterList)) { if (isNull(chapterList)) {
@ -52,7 +61,7 @@ public class PinkRosaScanProvider implements ContentProvider, PagedContentProvid
chapterTitleElement.text().trim(), chapterItemElement.attr("href"), null, null); chapterTitleElement.text().trim(), chapterItemElement.attr("href"), null, null);
}) })
.toList(); .toList();
} catch (NoSuchElementException | IOException e) { } catch (NoSuchElementException e) {
log.error("Error parsing mangas from Pink Rosa Scan", e); log.error("Error parsing mangas from Pink Rosa Scan", e);
return List.of(); return List.of();
} }
@ -60,8 +69,11 @@ public class PinkRosaScanProvider implements ContentProvider, PagedContentProvid
@Override @Override
public Map<Integer, String> getChapterImagesUrls(String chapterUrl) { public Map<Integer, String> getChapterImagesUrls(String chapterUrl) {
log.info("Getting images from {}, url {}", ContentProviders.PINK_ROSA_SCAN, chapterUrl);
try { try {
var document = webScrapperClientProxyService.scrapeToJsoupDocument(chapterUrl); var document =
flareService.getContentAsJsoupDocument(chapterUrl, ContentProviders.PINK_ROSA_SCAN);
var chapterImagesContainer = document.getElementById("pages"); var chapterImagesContainer = document.getElementById("pages");
if (isNull(chapterImagesContainer)) { if (isNull(chapterImagesContainer)) {
@ -87,7 +99,7 @@ public class PinkRosaScanProvider implements ContentProvider, PagedContentProvid
.collect( .collect(
Collectors.toMap( Collectors.toMap(
i -> i, imageUrls::get, (existing, replacement) -> existing, LinkedHashMap::new)); i -> i, imageUrls::get, (existing, replacement) -> existing, LinkedHashMap::new));
} catch (NoSuchElementException | IOException e) { } catch (NoSuchElementException e) {
log.error("Error parsing mangas from Pink Rosa Scan", e); log.error("Error parsing mangas from Pink Rosa Scan", e);
return Map.of(); return Map.of();
} }
@ -100,10 +112,10 @@ public class PinkRosaScanProvider implements ContentProvider, PagedContentProvid
@Override @Override
public List<ContentProviderMangaInfoResponseDTO> getMangasFromPage(Integer page) { public List<ContentProviderMangaInfoResponseDTO> getMangasFromPage(Integer page) {
log.info("Getting mangas from {}", ContentProviders.PINK_ROSA_SCAN);
try { try {
var document = var document = flareService.getContentAsJsoupDocument(URL, ContentProviders.PINK_ROSA_SCAN);
webScrapperClientProxyService.scrapeToJsoupDocument(
"https://scanpinkrosa.blogspot.com/search/label/Series?max-results=1000");
var mangaElements = var mangaElements =
document.getElementsByClass("grid relative sm:gap-3.5 gap-[2.5vw] w-full h-fit"); document.getElementsByClass("grid relative sm:gap-3.5 gap-[2.5vw] w-full h-fit");
@ -130,7 +142,7 @@ public class PinkRosaScanProvider implements ContentProvider, PagedContentProvid
title, url, null, MangaStatus.UNKNOWN); title, url, null, MangaStatus.UNKNOWN);
}) })
.toList(); .toList();
} catch (NoSuchElementException | IOException e) { } catch (NoSuchElementException e) {
log.error("Error parsing mangas from Pink Rosa Scan", e); log.error("Error parsing mangas from Pink Rosa Scan", e);
return List.of(); return List.of();
} }

View File

@ -0,0 +1,35 @@
package com.magamochi.mangamochi.task;
import com.magamochi.mangamochi.client.FlareClient;
import com.magamochi.mangamochi.registry.FlareSessionRegistry;
import java.time.Duration;
import java.time.Instant;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class FlareSessionCleanupTask {
private static final Duration TIMEOUT = Duration.ofMinutes(15);
private final FlareClient client;
private final FlareSessionRegistry registry;
@Scheduled(fixedDelayString = "1m")
public void cleanExpiredSessions() {
registry
.getSessions()
.forEach(
(provider, session) -> {
if (Duration.between(session.lastAccess(), Instant.now()).compareTo(TIMEOUT) <= 0) {
return;
}
client.destroySession(
FlareClient.SessionDestroyRequest.builder().session(session.sessionId()).build());
registry.remove(provider);
});
}
}

View File

@ -0,0 +1,26 @@
package com.magamochi.mangamochi.task;
import com.magamochi.mangamochi.client.FlareClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Log4j2
@Component
@RequiredArgsConstructor
public class FlareStartupCleanupTask {
private final FlareClient client;
@EventListener(ApplicationReadyEvent.class)
public void cleanupExistingSessions() {
var sessions = client.listSessions(FlareClient.SessionListRequest.builder().build()).sessions();
for (var sessionId : sessions) {
client.destroySession(FlareClient.SessionDestroyRequest.builder().session(sessionId).build());
}
log.info("FlareSolverr session cleanup completed on startup.");
}
}

View File

@ -1,6 +1,8 @@
package com.magamochi.mangamochi.task; package com.magamochi.mangamochi.task;
import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.model.dto.MangaListUpdateCommand; import com.magamochi.mangamochi.model.dto.MangaListUpdateCommand;
import com.magamochi.mangamochi.model.repository.ProviderRepository;
import com.magamochi.mangamochi.queue.UpdateMangaListProducer; import com.magamochi.mangamochi.queue.UpdateMangaListProducer;
import com.magamochi.mangamochi.service.providers.PagedContentProvider; import com.magamochi.mangamochi.service.providers.PagedContentProvider;
import com.magamochi.mangamochi.service.providers.PagedContentProviderFactory; import com.magamochi.mangamochi.service.providers.PagedContentProviderFactory;
@ -20,6 +22,7 @@ public class UpdateMangaListTask {
private final PagedContentProviderFactory contentProviderFactory; private final PagedContentProviderFactory contentProviderFactory;
private final UpdateMangaListProducer updateMangaListProducer; private final UpdateMangaListProducer updateMangaListProducer;
private final ProviderRepository providerRepository;
@Scheduled(cron = "${content-providers.cron-expression}") @Scheduled(cron = "${content-providers.cron-expression}")
public void updateMangaListScheduled() { public void updateMangaListScheduled() {
@ -37,6 +40,16 @@ public class UpdateMangaListTask {
contentProviders.forEach(this::updateProviderMangaList); contentProviders.forEach(this::updateProviderMangaList);
} }
public void updateProviderMangaList(Long providerId) {
var provider =
providerRepository
.findById(providerId)
.orElseThrow(() -> new NotFoundException("Provider not found"));
var contentProvider = contentProviderFactory.getPagedContentProvider(provider.getName());
updateProviderMangaList(provider.getName(), contentProvider);
}
private void updateProviderMangaList( private void updateProviderMangaList(
String contentProviderName, PagedContentProvider contentProvider) { String contentProviderName, PagedContentProvider contentProvider) {
log.info("Getting total pages for provider {}", contentProviderName); log.info("Getting total pages for provider {}", contentProviderName);
@ -45,10 +58,9 @@ public class UpdateMangaListTask {
IntStream.rangeClosed(1, pages) IntStream.rangeClosed(1, pages)
.forEach( .forEach(
page -> { page ->
updateMangaListProducer.sendUpdateMangaListCommand( updateMangaListProducer.sendUpdateMangaListCommand(
new MangaListUpdateCommand(contentProviderName, page)); new MangaListUpdateCommand(contentProviderName, page)));
});
log.info("Manga list update queued for content provider {}.", contentProviderName); log.info("Manga list update queued for content provider {}.", contentProviderName);
} }

View File

@ -38,6 +38,9 @@ springdoc:
web-scrapper: web-scrapper:
endpoint: ${WEBSCRAPPER_ENDPOINT} endpoint: ${WEBSCRAPPER_ENDPOINT}
flare-solverr:
endpoint: ${FLARESOLVERR_ENDPOINT}
minio: minio:
endpoint: ${MINIO_ENDPOINT} endpoint: ${MINIO_ENDPOINT}
accessKey: ${MINIO_USER} accessKey: ${MINIO_USER}
@ -59,6 +62,12 @@ manga-matcher:
resilience4j: resilience4j:
retry: retry:
instances: instances:
FlareSolverrRetry:
max-attempts: 2
wait-duration:
seconds: 5
retry-exceptions:
- feign.FeignException
MangaDexRetry: MangaDexRetry:
max-attempts: 5 max-attempts: 5
wait-duration: wait-duration: