mtg-decks-downloader

Tool to download Magic: The Gathering decklists from the Internet
git clone https://kevincorvisier.fr/git/mtg-decks-downloader.git
Log | Files | Refs | README

commit 65174f210d9ba5c3f74e6975fec36fc973bb5a95
parent b2ab13c40faaa7a945439c7c8271b9a146a5d53d
Author: Kevin Corvisier <git@kevincorvisier.fr>
Date:   Wed, 30 Oct 2024 09:30:02 +0900

Remove the deck content downloading code from the decklist consumer to
separate responsibilities
Diffstat:
Mlombok.config | 1+
Msrc/main/java/fr/kevincorvisier/mtg/dd/Cache.java | 61+++++++++++++++++++++++++++++++++++--------------------------
Msrc/main/java/fr/kevincorvisier/mtg/dd/StopCondition.java | 4++--
Msrc/main/java/fr/kevincorvisier/mtg/dd/consumers/DecklistConsumer.java | 7+++++--
Asrc/main/java/fr/kevincorvisier/mtg/dd/consumers/DecklistConsumersService.java | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/java/fr/kevincorvisier/mtg/dd/consumers/DefaultDecklistConsumer.java | 126+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Msrc/main/java/fr/kevincorvisier/mtg/dd/downloaders/HareruyaDecklistDownloader.java | 20+++++++++++---------
Msrc/main/java/fr/kevincorvisier/mtg/dd/downloaders/TcdecksDecklistDownloader.java | 15+++++++++------
Asrc/main/java/fr/kevincorvisier/mtg/dd/model/Deck.java | 18++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/model/DeckComparator.java | 23+++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/model/DeckContent.java | 11+++++++++++
Dsrc/main/java/fr/kevincorvisier/mtg/dd/model/DeckItem.java | 64----------------------------------------------------------------
Dsrc/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemComparator.java | 23-----------------------
Dsrc/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemFactory.java | 30------------------------------
Asrc/main/java/fr/kevincorvisier/mtg/dd/model/DeckMetadata.java | 21+++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/model/DeckMetadataFactory.java | 33+++++++++++++++++++++++++++++++++
Msrc/main/java/fr/kevincorvisier/mtg/dd/mtgo/MagicOnlineFileWriter.java | 6+++---
Msrc/main/java/fr/kevincorvisier/mtg/dd/validation/DeckValidator.java | 6+++---
Asrc/test/java/fr/kevincorvisier/mtg/dd/consumers/DefaultDecklistConsumerTest.java | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/test/java/fr/kevincorvisier/mtg/dd/model/DeckItemTest.java | 32--------------------------------
20 files changed, 358 insertions(+), 254 deletions(-)

diff --git a/lombok.config b/lombok.config @@ -1 +1,2 @@ +lombok.copyableAnnotations += javax.validation.constraints.NotNull lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value \ No newline at end of file diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/Cache.java b/src/main/java/fr/kevincorvisier/mtg/dd/Cache.java @@ -10,32 +10,33 @@ import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.time.LocalDate; -import java.util.Optional; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; import org.apache.commons.io.output.StringBuilderWriter; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import fr.kevincorvisier.mtg.dd.model.DeckItem; -import fr.kevincorvisier.mtg.dd.model.DeckItemFactory; +import fr.kevincorvisier.mtg.dd.model.DeckContent; import fr.kevincorvisier.mtg.dd.mtgo.MagicOnlineFileReader; import fr.kevincorvisier.mtg.dd.mtgo.MagicOnlineFileWriter; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service public class Cache { - private static final String QUERY_INITIALIZE = "CREATE TABLE IF NOT EXISTS `decks` (`url` TEXT PRIMARY KEY,`player` TEXT,`archetype` TEXT,`date` TEXT,`content` TEXT);"; + private static final String QUERY_INITIALIZE = "CREATE TABLE IF NOT EXISTS `decks` (`url` TEXT PRIMARY KEY,`content` TEXT);"; private final Connection connection; - private final DeckItemFactory deckItemFactory; - public Cache(final DeckItemFactory deckItemFactory, @Value("${cache.database}") final String file) throws SQLException + public Cache(@Value("${cache.database}") final String file) throws SQLException { if (file == null) throw new RuntimeException("Property cache.database must be present and have a value"); - this.deckItemFactory = deckItemFactory; connection = DriverManager.getConnection("jdbc:sqlite:" + file); initialize(); } @@ -48,43 +49,51 @@ public class Cache } } - public Optional<DeckItem> findByUrl(final URL url) throws SQLException, IOException + @Nullable + public DeckContent findByUrl(@NonNull @NotNull final URL url) { - try (PreparedStatement ps = connection.prepareStatement("SELECT * FROM decks WHERE url = ?")) + final String content; + + try (PreparedStatement ps = connection.prepareStatement("SELECT content FROM decks WHERE url = ?")) { ps.setString(1, url.toString()); final ResultSet rs = ps.executeQuery(); if (!rs.next()) - return Optional.empty(); - - final DeckItem deck = deckItemFactory.create(url, rs.getString("player"), rs.getString("archetype"), LocalDate.parse(rs.getString("date"))); + return null; - try (BufferedReader reader = new BufferedReader(new StringReader(rs.getString("content")))) - { - deck.setMain(MagicOnlineFileReader.read(reader)); - } + content = rs.getString("content"); + } + catch (final SQLException e) + { + log.error("findByUrl: url={}", url, e); + return null; + } - return Optional.of(deck); + try (BufferedReader reader = new BufferedReader(new StringReader(content))) + { + return new DeckContent(MagicOnlineFileReader.read(reader)); + } + catch (final NumberFormatException | IOException e) + { + // Cached content is not valid, return null + return null; } } - public void save(final DeckItem deck) throws SQLException, IOException + public void save(@NonNull @NotNull final URL url, @NonNull @NotNull final DeckContent content) throws SQLException, IOException { final StringBuilder builder = new StringBuilder(); try (BufferedWriter writer = new BufferedWriter(new StringBuilderWriter(builder))) { - MagicOnlineFileWriter.write(writer, deck); + MagicOnlineFileWriter.write(writer, content); } - try (PreparedStatement ps = connection.prepareStatement("INSERT INTO `decks` (`url`, `player`, `archetype`,`date`, `content`) VALUES (?, ?, ?, ?, ?);")) + try (PreparedStatement ps = connection.prepareStatement("INSERT INTO `decks` (`url`, `content`) VALUES (?, ?);")) { - ps.setString(1, deck.getUrl().toString()); - ps.setString(2, deck.getPlayer()); - ps.setString(3, deck.getArchetype()); - ps.setString(4, deck.getDate().toString()); - ps.setString(5, builder.toString()); + ps.setString(1, url.toString()); + ps.setString(2, builder.toString()); ps.executeUpdate(); } } diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/StopCondition.java b/src/main/java/fr/kevincorvisier/mtg/dd/StopCondition.java @@ -1,10 +1,10 @@ package fr.kevincorvisier.mtg.dd; -import fr.kevincorvisier.mtg.dd.model.DeckItem; +import fr.kevincorvisier.mtg.dd.model.DeckMetadata; public interface StopCondition { int capacity(); - int capacity(final DeckItem deck); + int capacity(final DeckMetadata deck); } diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/consumers/DecklistConsumer.java b/src/main/java/fr/kevincorvisier/mtg/dd/consumers/DecklistConsumer.java @@ -2,11 +2,14 @@ package fr.kevincorvisier.mtg.dd.consumers; import java.io.IOException; -import fr.kevincorvisier.mtg.dd.model.DeckItem; +import fr.kevincorvisier.mtg.dd.model.Deck; +import fr.kevincorvisier.mtg.dd.model.DeckMetadata; public interface DecklistConsumer { - void consume(final DeckItem deck); + boolean accept(final DeckMetadata metadata); + + void consume(final Deck deck); void saveToFolder() throws IOException; } diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/consumers/DecklistConsumersService.java b/src/main/java/fr/kevincorvisier/mtg/dd/consumers/DecklistConsumersService.java @@ -0,0 +1,57 @@ +package fr.kevincorvisier.mtg.dd.consumers; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.sql.SQLException; +import java.util.Collection; + +import org.springframework.stereotype.Service; + +import fr.kevincorvisier.mtg.dd.Cache; +import fr.kevincorvisier.mtg.dd.Crawler; +import fr.kevincorvisier.mtg.dd.model.Deck; +import fr.kevincorvisier.mtg.dd.model.DeckContent; +import fr.kevincorvisier.mtg.dd.model.DeckMetadata; +import fr.kevincorvisier.mtg.dd.mtgo.MagicOnlineFileReader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DecklistConsumersService +{ + private final Collection<DecklistConsumer> consumers; + private final Cache cache; + private final Crawler crawler; + + public void process(final DeckMetadata metadata) + { + final Collection<DecklistConsumer> acceptingConsumers = consumers.stream().filter(c -> c.accept(metadata)).toList(); + + // No consumer wants to consume this deck, do not download it + if (acceptingConsumers.isEmpty()) + return; + + DeckContent content = cache.findByUrl(metadata.getUrl()); + if (content == null) + { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(crawler.openStream(metadata.getUrl())))) + { + content = new DeckContent(MagicOnlineFileReader.read(reader)); + cache.save(metadata.getUrl(), content); + } + catch (SQLException | IOException e) + { + log.error("Error while downloading deck, skipping {}", metadata, e); + return; + } + } + + for (final DecklistConsumer consumer : acceptingConsumers) + { + consumer.consume(new Deck(metadata, content)); + } + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/consumers/DefaultDecklistConsumer.java b/src/main/java/fr/kevincorvisier/mtg/dd/consumers/DefaultDecklistConsumer.java @@ -1,11 +1,9 @@ package fr.kevincorvisier.mtg.dd.consumers; -import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; -import java.io.InputStreamReader; import java.nio.file.Files; import java.util.Collections; import java.util.HashMap; @@ -15,16 +13,17 @@ import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; + import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import fr.kevincorvisier.mtg.dd.Cache; -import fr.kevincorvisier.mtg.dd.Crawler; import fr.kevincorvisier.mtg.dd.StopCondition; -import fr.kevincorvisier.mtg.dd.model.DeckItem; -import fr.kevincorvisier.mtg.dd.model.DeckItemComparator; -import fr.kevincorvisier.mtg.dd.mtgo.MagicOnlineFileReader; +import fr.kevincorvisier.mtg.dd.model.Deck; +import fr.kevincorvisier.mtg.dd.model.DeckComparator; +import fr.kevincorvisier.mtg.dd.model.DeckMetadata; import fr.kevincorvisier.mtg.dd.mtgo.MagicOnlineFileWriter; import fr.kevincorvisier.mtg.dd.validation.DeckValidator; import lombok.RequiredArgsConstructor; @@ -35,13 +34,11 @@ import lombok.extern.slf4j.Slf4j; @RequiredArgsConstructor public class DefaultDecklistConsumer implements DecklistConsumer, StopCondition { - private final Map<String, Map<String, DeckItem>> downloadedByDeckNameByPlayerName = new HashMap<>(); + private final Map<String, Map<String, Deck>> downloadedByDeckNameByPlayerName = new HashMap<>(); private final Set<Integer> downloadedContent = new HashSet<>(); private final Map<String, Integer> countByArchetype = new HashMap<>(); - private final Cache cache; private final DeckValidator validator; - private final Crawler crawler; @Value("${only-ai-playable-cards}") private final boolean onlyAiPlayableCards; @@ -61,7 +58,7 @@ public class DefaultDecklistConsumer implements DecklistConsumer, StopCondition } @Override - public int capacity(final DeckItem deck) + public int capacity(final DeckMetadata deck) { final int globalCapacity = capacity(); final int archetypeCapacity = archetypeLimit - countByArchetype.getOrDefault(deck.getArchetype(), 0); @@ -69,7 +66,8 @@ public class DefaultDecklistConsumer implements DecklistConsumer, StopCondition return Math.min(archetypeCapacity, globalCapacity); } - private boolean accept(final DeckItem deck) + @Override + public boolean accept(final DeckMetadata deck) { if (capacity(deck) <= 0) return false; @@ -80,9 +78,9 @@ public class DefaultDecklistConsumer implements DecklistConsumer, StopCondition if (StringUtils.containsAnyIgnoreCase(playerName, ignorePlayers)) return false; // Ignore this player - final DeckItem previouslyDownloadedVersion = downloadedByDeckNameByPlayerName.getOrDefault(playerName, Collections.emptyMap()).get(deckName); + final Deck previouslyDownloadedVersion = downloadedByDeckNameByPlayerName.getOrDefault(playerName, Collections.emptyMap()).get(deckName); - if (previouslyDownloadedVersion != null && previouslyDownloadedVersion.getDate().isAfter(deck.getDate())) + if (previouslyDownloadedVersion != null && previouslyDownloadedVersion.getMetadata().getDate().isAfter(deck.getDate())) { log.info("consume: more recent version of {} {} already downloaded, ignoring", deck.getPlayer(), deck.getArchetype()); return false; @@ -92,44 +90,22 @@ public class DefaultDecklistConsumer implements DecklistConsumer, StopCondition } @Override - public void consume(final DeckItem deck) + public void consume(final Deck deck) { - try + if (downloadedContent.contains(deck.getContent().getMain().hashCode())) { - if (!accept(deck)) - return; - - final DeckItem cached = cache.findByUrl(deck.getUrl()).orElse(null); - if (cached != null) - deck.setMain(cached.getMain()); - else - { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(crawler.openStream(deck.getUrl())))) - { - deck.setMain(MagicOnlineFileReader.read(reader)); - } - cache.save(deck); - } - - if (downloadedContent.contains(deck.getMain().hashCode())) - { - log.warn("consume: ignore deck: same decklist already exists: {}", deck); - return; - } + log.warn("consume: ignore deck: same decklist already exists: {}", deck); + return; + } - if (!validator.validate(deck, onlyAiPlayableCards)) - return; + if (!validator.validate(deck, onlyAiPlayableCards)) + return; - downloadedByDeckNameByPlayerName.computeIfAbsent(deck.getPlayer().toLowerCase(), k -> new HashMap<>()) // - .put(deck.getArchetype().toLowerCase(), deck); + downloadedByDeckNameByPlayerName.computeIfAbsent(deck.getMetadata().getPlayer().toLowerCase(), k -> new HashMap<>()) // + .put(deck.getMetadata().getArchetype().toLowerCase(), deck); - downloadedContent.add(deck.getMain().hashCode()); - countByArchetype.merge(deck.getArchetype(), 1, (a, b) -> a + b); - } - catch (final Exception e) - { - log.error("Error while downloading deck, skipping {}", deck, e); - } + downloadedContent.add(deck.getContent().getMain().hashCode()); + countByArchetype.merge(deck.getMetadata().getArchetype(), 1, (a, b) -> a + b); } @Override @@ -138,9 +114,9 @@ public class DefaultDecklistConsumer implements DecklistConsumer, StopCondition if (!folder.exists()) folder.mkdirs(); - final Map<String, DeckItem> deckByFileName = downloadedByDeckNameByPlayerName.values().stream() // + final Map<String, Deck> deckByFileName = downloadedByDeckNameByPlayerName.values().stream() // .flatMap(v -> v.values().stream()) // - .collect(Collectors.toMap(DeckItem::getFilename, Function.identity())); + .collect(Collectors.toMap(d -> getFilename(d.getMetadata()), Function.identity())); for (final File file : folder.listFiles()) { @@ -154,19 +130,61 @@ public class DefaultDecklistConsumer implements DecklistConsumer, StopCondition log.info("Deleted {}", file); } - final Set<DeckItem> toSave = deckByFileName.values().stream() // - .sorted(DeckItemComparator.INSTANCE) // + final Set<Deck> toSave = deckByFileName.values().stream() // + .sorted(DeckComparator.INSTANCE) // .limit(limit) // .collect(Collectors.toUnmodifiableSet()); - for (final DeckItem deck : toSave) + for (final Deck deck : toSave) { - final File file = new File(folder, deck.getFilename()); + final File file = new File(folder, getFilename(deck.getMetadata())); try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) { - MagicOnlineFileWriter.write(writer, deck); + MagicOnlineFileWriter.write(writer, deck.getContent()); } log.info("Saved {}", file); } } + + /* package */ static String getFilename(@NotNull final DeckMetadata metadata) + { + return metadata.getDate().toString() + "_" + sanitize(metadata.getPlayer()) + "_" + sanitize(metadata.getArchetype()) + ".txt"; + } + + @Nullable + @SuppressWarnings("null") + private static String sanitize(@Nullable String s) + { + if (s == null) + return null; + + // Remove Katakana + s = s.replace("ア", "a").replace("イ", "i").replace("ウ", "u").replace("エ", "e").replace("オ", "o") // + .replace("キャ", "kya").replace("キュ", "kyu").replace("キョ", "kyo") // + .replace("カー", "kaa").replace("キー", "kii").replace("クー", "kuu").replace("ケー", "kee").replace("コー", "koo") // + .replace("カ", "ka").replace("キ", "ki").replace("ク", "ku").replace("ケ", "ke").replace("コ", "ko") // + .replace("ガ", "ga").replace("ギ", "gi").replace("グ", "gu").replace("ゲ", "ge").replace("ゴ", "go") // + .replace("シャ", "sha").replace("シュ", "shu").replace("ショ", "sho") // + .replace("サ", "sa").replace("シ", "shi").replace("ス", "su").replace("セ", "se").replace("ソ", "so") // + .replace("ザ", "za").replace("ジ", "zi").replace("ズ", "zu").replace("ゼ", "ze").replace("ゾ", "zo") // + .replace("タ", "ta").replace("チ", "chi").replace("ツ", "tsu").replace("テ", "te").replace("ト", "to") // + .replace("ダ", "da").replace("ヂ", "ji").replace("ヅ", "zu").replace("デ", "de").replace("ド", "do") // + .replace("ナ", "na").replace("ニ", "ni").replace("ヌ", "nu").replace("ネ", "ne").replace("ノ", "no") // + .replace("ハ", "ha").replace("ヒ", "hi").replace("フ", "fu").replace("ヘ", "he").replace("ホ", "ho") // + .replace("バ", "ba").replace("ビ", "bi").replace("ブ", "bu").replace("ベ", "be").replace("ボ", "bo") // + .replace("マー", "maa").replace("ミー", "mii").replace("ムー", "muu").replace("メー", "mee").replace("モー", "moo") // + .replace("マ", "ma").replace("ミ", "mi").replace("ム", "mu").replace("メ", "me").replace("モ", "mo") // + .replace("ヤ", "ya").replace("ユ", "yu").replace("ヨ", "yo")// + .replace("ラ", "ra").replace("リ", "ri").replace("ル", "ru").replace("レ", "re").replace("ロ", "ro") // + .replace("ヴァ", "va") // + .replace("ン", "n") // + .replace("\u3000", " "); + + // Remove accents + s = StringUtils.stripAccents(s); + // Remove special chars + s = s.replace("[", "").replace("]", "").replace("_", ""); + + return s.replace(" ", ""); + } } diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/downloaders/HareruyaDecklistDownloader.java b/src/main/java/fr/kevincorvisier/mtg/dd/downloaders/HareruyaDecklistDownloader.java @@ -9,18 +9,20 @@ import org.openqa.selenium.WebElement; import org.springframework.stereotype.Service; import fr.kevincorvisier.mtg.dd.Crawler; -import fr.kevincorvisier.mtg.dd.consumers.DecklistConsumer; -import fr.kevincorvisier.mtg.dd.model.DeckItem; -import fr.kevincorvisier.mtg.dd.model.DeckItemFactory; +import fr.kevincorvisier.mtg.dd.consumers.DecklistConsumersService; +import fr.kevincorvisier.mtg.dd.model.DeckMetadata; +import fr.kevincorvisier.mtg.dd.model.DeckMetadataFactory; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor public class HareruyaDecklistDownloader implements DecklistDownloader { private final Crawler crawler; - private final DeckItemFactory deckItemFactory; - private final DecklistConsumer consumer; + private final DeckMetadataFactory metadataFactory; + private final DecklistConsumersService consumers; @Override public boolean accept(final URL url) @@ -37,8 +39,8 @@ public class HareruyaDecklistDownloader implements DecklistDownloader { try { - final DeckItem deck = processDeckElement(element); - consumer.consume(deck); + final DeckMetadata deck = processDeckElement(element); + consumers.process(deck); } catch (final IOException e) { @@ -47,7 +49,7 @@ public class HareruyaDecklistDownloader implements DecklistDownloader } } - private DeckItem processDeckElement(final WebElement element) throws IOException + private DeckMetadata processDeckElement(final WebElement element) throws IOException { final String playerName = element.findElement(By.className("deckSearch-searchResult__item__playerName__link")).getText(); final String deckName = element.findElement(By.xpath(".//li[@class='deckSearch-searchResult__item__deckName']/span[2]")).getText(); @@ -61,7 +63,7 @@ public class HareruyaDecklistDownloader implements DecklistDownloader final long id = Long.parseLong(href.substring(beginIndex, endIndex)); final URL url = new URL("https://www.hareruyamtg.com/en/deck/download/" + id); - return deckItemFactory.create(url, playerName, deckName, LocalDate.parse(date)); + return metadataFactory.create(url, playerName, deckName, LocalDate.parse(date)); } private String parseDate(final WebElement element, final By by) diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/downloaders/TcdecksDecklistDownloader.java b/src/main/java/fr/kevincorvisier/mtg/dd/downloaders/TcdecksDecklistDownloader.java @@ -9,6 +9,8 @@ import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.validation.constraints.NotNull; + import org.openqa.selenium.By; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.WebElement; @@ -17,8 +19,8 @@ import org.springframework.stereotype.Service; import fr.kevincorvisier.mtg.dd.Crawler; import fr.kevincorvisier.mtg.dd.StopCondition; -import fr.kevincorvisier.mtg.dd.consumers.DecklistConsumer; -import fr.kevincorvisier.mtg.dd.model.DeckItemFactory; +import fr.kevincorvisier.mtg.dd.consumers.DecklistConsumersService; +import fr.kevincorvisier.mtg.dd.model.DeckMetadataFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -40,9 +42,9 @@ public class TcdecksDecklistDownloader implements DecklistDownloader private static final String FORMAT_DOWNLOAD_URL = "/download.php?ext=txt&id=%1$s&iddeck=%2$s"; private final Crawler crawler; - private final DeckItemFactory deckItemFactory; - private final DecklistConsumer consumer; + private final DeckMetadataFactory metadataFactory; private final StopCondition stopCondition; + private final DecklistConsumersService consumers; @Value("${tcdecks.tournament.players.min}") private final int tournamentMinPlayers; @@ -149,7 +151,7 @@ public class TcdecksDecklistDownloader implements DecklistDownloader } } - consumer.consume(deckItemFactory.create(downloadUrl, player.getText(), archetype, + consumers.process(metadataFactory.create(downloadUrl, player.getText(), archetype, LocalDate.parse(date.getText(), DateTimeFormatter.ofPattern("dd/MM/yyyy")))); } } while (stopCondition.capacity() > 0); @@ -184,7 +186,7 @@ public class TcdecksDecklistDownloader implements DecklistDownloader final WebElement player = tr.findElement(By.xpath("td[@data-th='Player']/a")); final URL downloadUrl = toDownloadUrl(archetype.getAttribute("href")); - consumer.consume(deckItemFactory.create(downloadUrl, player.getText(), archetype.getText(), date)); + consumers.process(metadataFactory.create(downloadUrl, player.getText(), archetype.getText(), date)); } } @@ -201,6 +203,7 @@ public class TcdecksDecklistDownloader implements DecklistDownloader } } + @NotNull private URL toDownloadUrl(final String deckUrl) throws MalformedURLException { final Matcher matcher = PATTERN_URL_DECK.matcher(deckUrl); diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/model/Deck.java b/src/main/java/fr/kevincorvisier/mtg/dd/model/Deck.java @@ -0,0 +1,18 @@ +package fr.kevincorvisier.mtg.dd.model; + +import javax.validation.constraints.NotNull; + +import lombok.Data; +import lombok.NonNull; + +@Data +public class Deck +{ + @NonNull + @NotNull + private final DeckMetadata metadata; + + @NonNull + @NotNull + private final DeckContent content; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckComparator.java b/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckComparator.java @@ -0,0 +1,23 @@ +package fr.kevincorvisier.mtg.dd.model; + +import java.util.Comparator; + +public class DeckComparator implements Comparator<Deck> +{ + public static final DeckComparator INSTANCE = new DeckComparator(); + + @Override + public int compare(final Deck o1, final Deck o2) + { + // More recent first + int res = -o1.getMetadata().getDate().compareTo(o2.getMetadata().getDate()); + if (res != 0) + return res; + + res = o1.getMetadata().getPlayer().compareTo(o2.getMetadata().getPlayer()); + if (res != 0) + return res; + + return o1.getMetadata().getArchetype().compareTo(o2.getMetadata().getArchetype()); + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckContent.java b/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckContent.java @@ -0,0 +1,11 @@ +package fr.kevincorvisier.mtg.dd.model; + +import java.util.Map; + +import lombok.Data; + +@Data +public class DeckContent +{ + private final Map<String, Integer> main; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItem.java b/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItem.java @@ -1,64 +0,0 @@ -package fr.kevincorvisier.mtg.dd.model; - -import java.net.URL; -import java.time.LocalDate; -import java.util.Collections; -import java.util.Map; - -import org.apache.commons.lang3.StringUtils; - -import lombok.AccessLevel; -import lombok.Data; -import lombok.RequiredArgsConstructor; - -@Data -@RequiredArgsConstructor(access = AccessLevel.PACKAGE) -public class DeckItem -{ - private final URL url; - private final String player; - private final String archetype; - private final LocalDate date; - - private Map<String, Integer> main = Collections.emptyMap(); - - public String getFilename() - { - return date.toString() + "_" + sanitize(player) + "_" + sanitize(archetype) + ".txt"; - } - - private static String sanitize(String s) - { - if (s == null) - return null; - - // Remove Katakana - s = s.replace("ア", "a").replace("イ", "i").replace("ウ", "u").replace("エ", "e").replace("オ", "o") // - .replace("キャ", "kya").replace("キュ", "kyu").replace("キョ", "kyo") // - .replace("カー", "kaa").replace("キー", "kii").replace("クー", "kuu").replace("ケー", "kee").replace("コー", "koo") // - .replace("カ", "ka").replace("キ", "ki").replace("ク", "ku").replace("ケ", "ke").replace("コ", "ko") // - .replace("ガ", "ga").replace("ギ", "gi").replace("グ", "gu").replace("ゲ", "ge").replace("ゴ", "go") // - .replace("シャ", "sha").replace("シュ", "shu").replace("ショ", "sho") // - .replace("サ", "sa").replace("シ", "shi").replace("ス", "su").replace("セ", "se").replace("ソ", "so") // - .replace("ザ", "za").replace("ジ", "zi").replace("ズ", "zu").replace("ゼ", "ze").replace("ゾ", "zo") // - .replace("タ", "ta").replace("チ", "chi").replace("ツ", "tsu").replace("テ", "te").replace("ト", "to") // - .replace("ダ", "da").replace("ヂ", "ji").replace("ヅ", "zu").replace("デ", "de").replace("ド", "do") // - .replace("ナ", "na").replace("ニ", "ni").replace("ヌ", "nu").replace("ネ", "ne").replace("ノ", "no") // - .replace("ハ", "ha").replace("ヒ", "hi").replace("フ", "fu").replace("ヘ", "he").replace("ホ", "ho") // - .replace("バ", "ba").replace("ビ", "bi").replace("ブ", "bu").replace("ベ", "be").replace("ボ", "bo") // - .replace("マー", "maa").replace("ミー", "mii").replace("ムー", "muu").replace("メー", "mee").replace("モー", "moo") // - .replace("マ", "ma").replace("ミ", "mi").replace("ム", "mu").replace("メ", "me").replace("モ", "mo") // - .replace("ヤ", "ya").replace("ユ", "yu").replace("ヨ", "yo")// - .replace("ラ", "ra").replace("リ", "ri").replace("ル", "ru").replace("レ", "re").replace("ロ", "ro") // - .replace("ヴァ", "va") // - .replace("ン", "n") // - .replace(" ", " "); - - // Remove accents - s = StringUtils.stripAccents(s); - // Remove special chars - s = s.replace("[", "").replace("]", "").replace("_", ""); - - return s.replace(" ", ""); - } -} diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemComparator.java b/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemComparator.java @@ -1,23 +0,0 @@ -package fr.kevincorvisier.mtg.dd.model; - -import java.util.Comparator; - -public class DeckItemComparator implements Comparator<DeckItem> -{ - public static final DeckItemComparator INSTANCE = new DeckItemComparator(); - - @Override - public int compare(final DeckItem o1, final DeckItem o2) - { - // More recent first - int res = -o1.getDate().compareTo(o2.getDate()); - if (res != 0) - return res; - - res = o1.getPlayer().compareTo(o2.getPlayer()); - if (res != 0) - return res; - - return o1.getArchetype().compareTo(o2.getArchetype()); - } -} diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemFactory.java b/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemFactory.java @@ -1,30 +0,0 @@ -package fr.kevincorvisier.mtg.dd.model; - -import java.net.URL; -import java.time.LocalDate; -import java.util.Map; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Service -@RequiredArgsConstructor -public class DeckItemFactory -{ - @Value("${replace-archetypes}") - private final Map<String, String> replaceArchetypes; - - public DeckItem create(final URL url, final String player, final String archetype, final LocalDate date) - { - final String archetypeReplacement = replaceArchetypes.get(archetype); - - if (archetypeReplacement != null) - log.info("Replaced {} by {}", archetype, archetypeReplacement); - - return new DeckItem(url, player, archetypeReplacement != null ? archetypeReplacement : archetype, date); - } -} diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckMetadata.java b/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckMetadata.java @@ -0,0 +1,21 @@ +package fr.kevincorvisier.mtg.dd.model; + +import java.net.URL; +import java.time.LocalDate; + +import javax.validation.constraints.NotNull; + +import lombok.Data; +import lombok.NonNull; + +@Data +public class DeckMetadata +{ + @NonNull + @NotNull + private final URL url; + + private final String player; + private final String archetype; + private final LocalDate date; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckMetadataFactory.java b/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckMetadataFactory.java @@ -0,0 +1,33 @@ +package fr.kevincorvisier.mtg.dd.model; + +import java.net.URL; +import java.time.LocalDate; +import java.util.Map; + +import javax.validation.constraints.NotNull; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeckMetadataFactory +{ + @Value("${replace-archetypes}") + private final Map<String, String> replaceArchetypes; + + public DeckMetadata create(@NonNull @NotNull final URL url, final String player, final String archetype, final LocalDate date) + { + final String archetypeReplacement = replaceArchetypes.get(archetype); + + if (archetypeReplacement != null) + log.info("Replaced {} by {}", archetype, archetypeReplacement); + + return new DeckMetadata(url, player, archetypeReplacement != null ? archetypeReplacement : archetype, date); + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/mtgo/MagicOnlineFileWriter.java b/src/main/java/fr/kevincorvisier/mtg/dd/mtgo/MagicOnlineFileWriter.java @@ -4,16 +4,16 @@ import java.io.BufferedWriter; import java.io.IOException; import java.util.Map.Entry; -import fr.kevincorvisier.mtg.dd.model.DeckItem; +import fr.kevincorvisier.mtg.dd.model.DeckContent; import lombok.AccessLevel; import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PACKAGE) // static class public class MagicOnlineFileWriter { - public static void write(final BufferedWriter writer, final DeckItem deck) throws IOException + public static void write(final BufferedWriter writer, final DeckContent content) throws IOException { - for (final Entry<String, Integer> card : deck.getMain().entrySet()) + for (final Entry<String, Integer> card : content.getMain().entrySet()) { writer.append(Integer.toString(card.getValue())).append(' ').append(sanitizeCardName(card.getKey())).append('\n'); } diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/validation/DeckValidator.java b/src/main/java/fr/kevincorvisier/mtg/dd/validation/DeckValidator.java @@ -10,7 +10,7 @@ import java.util.Map.Entry; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import fr.kevincorvisier.mtg.dd.model.DeckItem; +import fr.kevincorvisier.mtg.dd.model.Deck; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,11 +35,11 @@ public class DeckValidator } } - public boolean validate(final DeckItem deck, final boolean onlyAiPlayableCards) + public boolean validate(final Deck deck, final boolean onlyAiPlayableCards) { int cardCount = 0; - for (final Entry<String, Integer> card : deck.getMain().entrySet()) + for (final Entry<String, Integer> card : deck.getContent().getMain().entrySet()) { final String cardName = card.getKey(); if (banlist.contains(cardName)) diff --git a/src/test/java/fr/kevincorvisier/mtg/dd/consumers/DefaultDecklistConsumerTest.java b/src/test/java/fr/kevincorvisier/mtg/dd/consumers/DefaultDecklistConsumerTest.java @@ -0,0 +1,54 @@ +package fr.kevincorvisier.mtg.dd.consumers; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.MalformedURLException; +import java.net.URL; +import java.time.LocalDate; + +import javax.validation.constraints.NotNull; + +import org.junit.jupiter.api.Test; + +import fr.kevincorvisier.mtg.dd.model.DeckMetadata; + +class DefaultDecklistConsumerTest +{ + @Test + void getFilename_playerName_accents() + { + assertEquals("2024-05-04_LukasKovarik_Burn.txt", DefaultDecklistConsumer.getFilename(deck("2024-05-04", "Lukáš Kovařík", "Burn"))); + assertEquals("2024-06-08_MichalRadecki_Goblins.txt", DefaultDecklistConsumer.getFilename(deck("2024-06-08", "Michał Radecki", "Goblins"))); + + } + + @Test + void getFilename_playerName_katakanas() + { + assertEquals("2023-02-25_vagunemaakukaaru_SnakeTongueMiddleSchool.txt", + DefaultDecklistConsumer.getFilename(deck("2023-02-25", "ヴァグネマーク カール", "Snake Tongue[Middle School]"))); + assertEquals("2024-05-09_mukaitakashi_JeskaiBlink.txt", DefaultDecklistConsumer.getFilename(deck("2024-05-09", "ムカイ タカシ", "Jeskai Blink"))); + assertEquals("2024-05-23_ishiyamakyouichi_KuldothaRed.txt", DefaultDecklistConsumer.getFilename(deck("2024-05-23", "イシヤマ キョウイチ", "Kuldotha Red"))); + assertEquals("2024-05-19_kiharamanabu_MonoRed.txt", DefaultDecklistConsumer.getFilename(deck("2024-05-19", "キハラ マナブ", "Mono Red"))); + } + + @Test + void getFilename_archetype_brackets() + { + assertEquals("2024-05-13_TomoyaIshibashi_OathMiddleSchool.txt", + DefaultDecklistConsumer.getFilename(deck("2024-05-13", "Tomoya Ishibashi", "Oath[Middle School]"))); + } + + @NotNull + private static DeckMetadata deck(final String date, final String player, final String archetype) + { + try + { + return new DeckMetadata(new URL("https://localhost"), player, archetype, LocalDate.parse(date)); + } + catch (final MalformedURLException e) + { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/fr/kevincorvisier/mtg/dd/model/DeckItemTest.java b/src/test/java/fr/kevincorvisier/mtg/dd/model/DeckItemTest.java @@ -1,32 +0,0 @@ -package fr.kevincorvisier.mtg.dd.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.time.LocalDate; - -import org.junit.jupiter.api.Test; - -class DeckItemTest -{ - @Test - void playerNameSanitized() - { - assertEquals("2024-05-04_LukasKovarik_Burn.txt", deck("2024-05-04", "Lukáš Kovařík", "Burn").getFilename()); - assertEquals("2024-06-08_MichalRadecki_Goblins.txt", deck("2024-06-08", "Michał Radecki", "Goblins").getFilename()); - assertEquals("2023-02-25_vagunemaakukaaru_SnakeTongueMiddleSchool.txt", deck("2023-02-25", "ヴァグネマーク カール", "Snake Tongue[Middle School]").getFilename()); - assertEquals("2024-05-09_mukaitakashi_JeskaiBlink.txt", deck("2024-05-09", "ムカイ タカシ", "Jeskai Blink").getFilename()); - assertEquals("2024-05-23_ishiyamakyouichi_KuldothaRed.txt", deck("2024-05-23", "イシヤマ キョウイチ", "Kuldotha Red").getFilename()); - assertEquals("2024-05-19_kiharamanabu_MonoRed.txt", deck("2024-05-19", "キハラ マナブ", "Mono Red").getFilename()); - } - - @Test - void archetypeSanitized() - { - assertEquals("2024-05-13_TomoyaIshibashi_OathMiddleSchool.txt", deck("2024-05-13", "Tomoya Ishibashi", "Oath[Middle School]").getFilename()); - } - - private static DeckItem deck(final String date, final String player, final String archetype) - { - return new DeckItem(null, player, archetype, LocalDate.parse(date)); - } -}