Compare commits
No commits in common. "tools" and "main" have entirely different histories.
18 changed files with 997 additions and 1990 deletions
2115
Cargo.lock
generated
2115
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
44
Cargo.toml
44
Cargo.toml
|
|
@ -1,42 +1,12 @@
|
|||
[package]
|
||||
name = "ptcg-tools"
|
||||
name = "ptcg-scrap"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
camino = "1.2.2"
|
||||
clap = { version = "4.5.53", features = ["derive"] }
|
||||
directories = "6.0.0"
|
||||
fluent-templates = "0.13.2"
|
||||
futures = { version = "0.3.31", default-features = false }
|
||||
parquet = { version = "57.1.0", default-features = false, features = ["arrow", "async", "simdutf8", "snap"] }
|
||||
reqwest = { version = "0.12.28", default-features = false, features = [
|
||||
"brotli",
|
||||
"http2",
|
||||
"gzip",
|
||||
"json",
|
||||
"rustls-tls-native-roots",
|
||||
"stream",
|
||||
] }
|
||||
serde = { version = "1.0.228", default-features = false, features = [
|
||||
"derive",
|
||||
"std",
|
||||
] }
|
||||
serde_json = "1.0.148"
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
tokio = { version = "1.48.0", default-features = false, features = [
|
||||
"fs",
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
] }
|
||||
tokio-util = { version = "0.7.17", default-features = false, features = ["io"] }
|
||||
tokio-stream = { version = "0.1.17", default-features = false }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.22", default-features = false, features = [
|
||||
"ansi",
|
||||
"env-filter",
|
||||
"fmt",
|
||||
"tracing",
|
||||
"tracing-log",
|
||||
] }
|
||||
anyhow = "1.0.97"
|
||||
clap = { version = "4.5.35", features = ["derive"] }
|
||||
reqwest = { version = "0.12.15", default-features = false, features = ["http2", "rustls-tls"] }
|
||||
scraper = "0.23.1"
|
||||
strum = { version = "0.27.1", features = ["derive"] }
|
||||
tokio = { version = "1.44.1", default-features = false, features = ["fs", "rt-multi-thread", "macros"] }
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
## PTCG Tools
|
||||
## PTCG Scrapper
|
||||
|
|
|
|||
80
cliff.toml
80
cliff.toml
|
|
@ -1,80 +0,0 @@
|
|||
[changelog]
|
||||
# changelog header
|
||||
header = """
|
||||
# Changelog\n
|
||||
"""
|
||||
# template for the changelog body
|
||||
# https://tera.netlify.app/docs
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
# remove the leading and trailing whitespace from the template
|
||||
trim = true
|
||||
# changelog footer
|
||||
footer = """
|
||||
"""
|
||||
# postprocessors
|
||||
postprocessors = [
|
||||
{ pattern = '<REPO>', replace = "https://oolong.ludwig.dog/pitbuster/ptcg-tools" },
|
||||
]
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# filter out the commits that are not conventional
|
||||
filter_unconventional = true
|
||||
# process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
# regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))" }, # replace issue numbers
|
||||
]
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features" },
|
||||
{ message = "^fix", group = "Bug Fixes" },
|
||||
{ message = "^doc", group = "Documentation" },
|
||||
{ message = "^chore(docs)", group = "Documentation" },
|
||||
{ message = "^perf", group = "Performance" },
|
||||
{ message = "^refactor", group = "Refactor" },
|
||||
{ message = "^style", group = "Styling" },
|
||||
{ message = "^test", group = "Testing" },
|
||||
{ message = "^release:", skip = true },
|
||||
{ message = "^chore\\(release\\):", skip = true },
|
||||
{ message = "^chore\\(changelog\\):", skip = true },
|
||||
{ message = "^chore\\(deps\\)", skip = true },
|
||||
{ message = "^chore\\(pr\\)", skip = true },
|
||||
{ message = "^chore\\(pull\\)", skip = true },
|
||||
{ message = "^chore|ci", group = "Miscellaneous Tasks" },
|
||||
{ body = ".*security", group = "Security" },
|
||||
{ message = "^revert", group = "Revert" },
|
||||
]
|
||||
# extract external references
|
||||
link_parsers = [
|
||||
{ pattern = "#(\\d+)", href = "https://oolong.ludwig.dog/pitbuster/ptcg-tools/issues/$1" },
|
||||
]
|
||||
# protect breaking changes from being skipped due to matching a skipping commit_parser
|
||||
protect_breaking_commits = false
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = false
|
||||
# glob pattern for matching git tags
|
||||
tag_pattern = "[0-9]*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
# regex for ignoring tags
|
||||
ignore_tags = ""
|
||||
# sort the tags topologically
|
||||
topo_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "oldest"
|
||||
# limit the number of commits included in the changelog.
|
||||
# limit_commits = 42
|
||||
45
src/card.rs
Normal file
45
src/card.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//! Card info
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CardInfo {
|
||||
pub slug: String,
|
||||
pub inner: InnerCardInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InnerCardInfo {
|
||||
pub name: String,
|
||||
pub kind: CardKind,
|
||||
pub card_type: CardType,
|
||||
pub acespec: bool,
|
||||
pub tagteam: bool,
|
||||
pub future: bool,
|
||||
pub ancient: bool,
|
||||
pub specific_info: SpecificInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CardKind {
|
||||
Pokemon,
|
||||
Trainer,
|
||||
Energy,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CardType {
|
||||
Basic,
|
||||
Stage1,
|
||||
Stage2,
|
||||
Item,
|
||||
Tool,
|
||||
Supporter,
|
||||
Stadium,
|
||||
Special,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SpecificInfo {
|
||||
PokemonInfo {},
|
||||
TrainerInfo { effect: Vec<String> },
|
||||
EnergyInfo {},
|
||||
}
|
||||
27
src/cli.rs
27
src/cli.rs
|
|
@ -1,26 +1,15 @@
|
|||
//! CLI parameters
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::lang::Language;
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Args {
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand, PartialEq)]
|
||||
pub enum Command {
|
||||
/// Downloads the card data
|
||||
DownloadData {
|
||||
/// Language to download the data in
|
||||
#[arg(short, value_parser=<Language as FromStr>::from_str)]
|
||||
lang: Language,
|
||||
},
|
||||
/// Terminal User Interface
|
||||
Tui,
|
||||
/// Edition code
|
||||
pub code: String,
|
||||
/// Card number within the edition
|
||||
pub number: u8,
|
||||
///Override the slug for the card
|
||||
#[arg(short, long)]
|
||||
pub slug: Option<String>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
//! Application wide constants.
|
||||
|
||||
pub const APP_NAME: &str = "ptcg-tools";
|
||||
pub const SNAKE_CASE_APP_NAME: &str = "ptcg_tools";
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
//! Local data store
|
||||
|
||||
use anyhow::Result;
|
||||
use camino::Utf8PathBuf;
|
||||
use parquet::arrow::AsyncArrowWriter;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{directories::data_cache_directory, malie::models::Index};
|
||||
|
||||
pub struct Store {
|
||||
data_cache_directory: Utf8PathBuf,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub async fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
data_cache_directory: data_cache_directory().await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn write_index(&self, index: Index) -> Result<()> {
|
||||
let path = self.data_cache_directory.join("ptcgl_index.parquet");
|
||||
if let Ok(true) = tokio::fs::try_exists(&path).await {
|
||||
debug!("File {path} already exists, skipping.");
|
||||
return Ok(());
|
||||
}
|
||||
// let mut writer = AsyncArrowWriter::try_new(writer, arrow_schema, props)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
//! User directories handling
|
||||
use anyhow::{Result, anyhow};
|
||||
use camino::Utf8PathBuf;
|
||||
use directories::ProjectDirs;
|
||||
|
||||
use crate::constants::APP_NAME;
|
||||
|
||||
/// Returns the path to the user data directory.
|
||||
///
|
||||
/// Post condition: this function ensures the directory is already created when returning.
|
||||
pub async fn data_directory() -> Result<Utf8PathBuf> {
|
||||
let user_directory = ProjectDirs::from("cl", "bstr", APP_NAME)
|
||||
.ok_or_else(|| anyhow!("failed to get ProjectDirs"))?
|
||||
.data_dir()
|
||||
.to_path_buf();
|
||||
let user_directory = Utf8PathBuf::try_from(user_directory)?;
|
||||
tokio::fs::create_dir_all(&user_directory).await?;
|
||||
Ok(user_directory)
|
||||
}
|
||||
|
||||
/// Returns the path to the user data cache directory.
|
||||
///
|
||||
/// Post condition: this function ensures the directory is already created when returning.
|
||||
pub async fn data_cache_directory() -> Result<Utf8PathBuf> {
|
||||
let user_directory = ProjectDirs::from("cl", "bstr", APP_NAME)
|
||||
.ok_or_else(|| anyhow!("failed to get ProjectDirs"))?
|
||||
.cache_dir()
|
||||
.to_path_buf();
|
||||
let user_directory = Utf8PathBuf::try_from(user_directory)?;
|
||||
let user_directory = user_directory.join("data");
|
||||
tokio::fs::create_dir_all(&user_directory).await?;
|
||||
Ok(user_directory)
|
||||
}
|
||||
|
||||
/// Returns the path to the user data cache directory.
|
||||
///
|
||||
/// Post condition: this function ensures the directory is already created when returning.
|
||||
pub async fn image_cache_directory() -> Result<Utf8PathBuf> {
|
||||
let user_directory = ProjectDirs::from("cl", "bstr", APP_NAME)
|
||||
.ok_or_else(|| anyhow!("failed to get ProjectDirs"))?
|
||||
.cache_dir()
|
||||
.to_path_buf();
|
||||
let user_directory = Utf8PathBuf::try_from(user_directory)?;
|
||||
let user_directory = user_directory.join("data");
|
||||
tokio::fs::create_dir_all(&user_directory).await?;
|
||||
Ok(user_directory)
|
||||
}
|
||||
231
src/downloader/card_info.rs
Normal file
231
src/downloader/card_info.rs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
//! Download card information.
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use reqwest::Client;
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
use crate::card::{CardInfo, CardKind, CardType, InnerCardInfo, SpecificInfo};
|
||||
use crate::editions::EditionCode;
|
||||
use crate::lang::Language;
|
||||
|
||||
pub async fn download_card_info(
|
||||
client: Client,
|
||||
lang: Language,
|
||||
code: EditionCode,
|
||||
number: u8,
|
||||
override_slug: Option<&str>,
|
||||
) -> Result<CardInfo> {
|
||||
let url = format!(
|
||||
"{}/{}/{number}/",
|
||||
base_url(lang),
|
||||
code.edition_num().to_lowercase()
|
||||
);
|
||||
let response = client.get(url).send().await?;
|
||||
response.error_for_status_ref()?;
|
||||
let (mut slug, inner) = parse_html(lang, code, response.text().await?)?;
|
||||
if let Some(override_slug) = override_slug {
|
||||
slug = override_slug.into()
|
||||
}
|
||||
Ok(CardInfo { slug, inner })
|
||||
}
|
||||
|
||||
fn base_url(lang: Language) -> &'static str {
|
||||
match lang {
|
||||
Language::Es => "https://www.pokemon.com/el/jcc-pokemon/cartas-pokemon/series",
|
||||
Language::En => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_html(lang: Language, code: EditionCode, html: String) -> Result<(String, InnerCardInfo)> {
|
||||
let html = Html::parse_document(&html);
|
||||
let card = html
|
||||
.select(&selector("div.full-card-information")?)
|
||||
.next()
|
||||
.ok_or(anyhow!("Couldn't find card info"))?;
|
||||
let name = card
|
||||
.select(&selector("h1")?)
|
||||
.next()
|
||||
.ok_or(anyhow!("Failed to get card name"))?
|
||||
.inner_html();
|
||||
let (kind, card_type) = parse_card_type(
|
||||
lang,
|
||||
card.select(&selector("div.card-type > h2")?)
|
||||
.next()
|
||||
.ok_or(anyhow!("Failed to get card type"))?
|
||||
.inner_html(),
|
||||
)?;
|
||||
let slug = match kind {
|
||||
CardKind::Pokemon => slugify_pokemon(lang, code, &name),
|
||||
CardKind::Trainer | CardKind::Energy => slugify_unique(lang, &name),
|
||||
};
|
||||
let specific_info = match kind {
|
||||
CardKind::Pokemon => SpecificInfo::PokemonInfo {},
|
||||
CardKind::Trainer => {
|
||||
let effect = card
|
||||
.select(&selector("div.ability > pre > p")?)
|
||||
.map(|e| e.inner_html())
|
||||
.collect();
|
||||
SpecificInfo::TrainerInfo { effect }
|
||||
}
|
||||
CardKind::Energy => SpecificInfo::EnergyInfo {},
|
||||
};
|
||||
|
||||
Ok((
|
||||
slug,
|
||||
InnerCardInfo {
|
||||
name,
|
||||
kind,
|
||||
card_type,
|
||||
acespec: false,
|
||||
tagteam: false,
|
||||
future: false,
|
||||
ancient: false,
|
||||
specific_info,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn selector(sel: &str) -> Result<Selector> {
|
||||
Selector::parse(sel).map_err(|_| anyhow!("failed to parse selector"))
|
||||
}
|
||||
|
||||
fn parse_card_type(lang: Language, text: String) -> Result<(CardKind, CardType)> {
|
||||
let kind = if text.contains(trainer_pattern(lang)) || text.contains(tool_pattern(lang)) {
|
||||
Ok(CardKind::Trainer)
|
||||
} else if text.contains("Pokémon") {
|
||||
Ok(CardKind::Pokemon)
|
||||
} else if text.contains(energy_pattern(lang)) {
|
||||
Ok(CardKind::Energy)
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Failed to get card kind (Pokemon, Trainer or Energy)"
|
||||
))
|
||||
}?;
|
||||
|
||||
let card_type = match kind {
|
||||
CardKind::Pokemon => {
|
||||
if text.contains(basic_pattern(lang)) {
|
||||
Ok(CardType::Basic)
|
||||
} else if text.contains(stage1_pattern(lang)) {
|
||||
Ok(CardType::Stage1)
|
||||
} else if text.contains(stage2_pattern(lang)) {
|
||||
Ok(CardType::Stage2)
|
||||
} else {
|
||||
Err(anyhow!("Failed to get Pokemon type: {text}"))
|
||||
}
|
||||
}
|
||||
CardKind::Trainer => {
|
||||
if text.contains(item_pattern(lang)) {
|
||||
Ok(CardType::Item)
|
||||
} else if text.contains(tool_pattern(lang)) {
|
||||
Ok(CardType::Tool)
|
||||
} else if text.contains(stadium_pattern(lang)) {
|
||||
Ok(CardType::Stadium)
|
||||
} else if text.contains(supporter_pattern(lang)) {
|
||||
Ok(CardType::Supporter)
|
||||
} else {
|
||||
Err(anyhow!("Failed to get Trainer type"))
|
||||
}
|
||||
}
|
||||
CardKind::Energy => {
|
||||
if text.contains(basic_pattern(lang)) {
|
||||
Ok(CardType::Basic)
|
||||
} else if text.contains(special_pattern(lang)) {
|
||||
Ok(CardType::Special)
|
||||
} else {
|
||||
Err(anyhow!("Failed to get Pokemon type"))
|
||||
}
|
||||
}
|
||||
}?;
|
||||
|
||||
Ok((kind, card_type))
|
||||
}
|
||||
|
||||
fn trainer_pattern(lang: Language) -> &'static str {
|
||||
match lang {
|
||||
Language::Es => "Entrenador",
|
||||
Language::En => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn energy_pattern(lang: Language) -> &'static str {
|
||||
match lang {
|
||||
Language::Es => "Energía",
|
||||
Language::En => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn basic_pattern(lang: Language) -> &'static str {
|
||||
match lang {
|
||||
Language::Es => "Básic",
|
||||
Language::En => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stage1_pattern(lang: Language) -> &'static str {
|
||||
match lang {
|
||||
Language::Es => "Fase 1",
|
||||
Language::En => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stage2_pattern(lang: Language) -> &'static str {
|
||||
match lang {
|
||||
Language::Es => "Fase 2",
|
||||
Language::En => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn item_pattern(lang: Language) -> &'static str {
|
||||
match lang {
|
||||
Language::Es => "Objeto",
|
||||
Language::En => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_pattern(lang: Language) -> &'static str {
|
||||
match lang {
|
||||
Language::Es => "Herramienta",
|
||||
Language::En => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn supporter_pattern(lang: Language) -> &'static str {
|
||||
match lang {
|
||||
Language::Es => "Partidario",
|
||||
Language::En => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stadium_pattern(lang: Language) -> &'static str {
|
||||
match lang {
|
||||
Language::Es => "Estadio",
|
||||
Language::En => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn special_pattern(lang: Language) -> &'static str {
|
||||
match lang {
|
||||
Language::Es => "Especial",
|
||||
Language::En => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn slugify_pokemon(lang: Language, code: EditionCode, name: &str) -> String {
|
||||
format!("{}-{code}-{lang}", slugify(name))
|
||||
}
|
||||
|
||||
fn slugify_unique(lang: Language, name: &str) -> String {
|
||||
format!("{}-{lang}", slugify(name))
|
||||
}
|
||||
|
||||
fn slugify(name: &str) -> String {
|
||||
name.to_lowercase()
|
||||
.replace("'s", "")
|
||||
.replace(" ", "-")
|
||||
.replace("á", "a")
|
||||
.replace("é", "e")
|
||||
.replace("í", "i")
|
||||
.replace("ó", "o")
|
||||
.replace("ú", "u")
|
||||
}
|
||||
3
src/downloader/mod.rs
Normal file
3
src/downloader/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//! Data downloaders
|
||||
|
||||
pub mod card_info;
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
//! Editions information
|
||||
|
||||
use serde::Deserialize;
|
||||
use strum::{Display, EnumString};
|
||||
|
||||
pub enum EditionBlock {
|
||||
|
|
@ -9,19 +8,14 @@ pub enum EditionBlock {
|
|||
Sm,
|
||||
Ssh,
|
||||
Sv,
|
||||
Meg,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Display, Debug, Hash, PartialEq, Eq, EnumString, Deserialize)]
|
||||
#[derive(Clone, Copy, Display, Debug, Hash, PartialEq, Eq, EnumString)]
|
||||
#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum EditionCode {
|
||||
/// Sword and Shield
|
||||
Ssh,
|
||||
/// SV Promos
|
||||
#[serde(alias = "PR-SV")]
|
||||
Svp,
|
||||
/// Scarlet and Violet
|
||||
/// Scarlet and Violer
|
||||
Svi,
|
||||
/// Paldea Evolved
|
||||
Pal,
|
||||
|
|
@ -45,27 +39,12 @@ pub enum EditionCode {
|
|||
Ssp,
|
||||
/// Prismatic Evolutions
|
||||
Pre,
|
||||
/// Journey Together
|
||||
Jtg,
|
||||
/// Destined Rivals
|
||||
Dri,
|
||||
/// Black Bolt
|
||||
Blk,
|
||||
/// White Flare
|
||||
Wht,
|
||||
/// Mega Evolution Promos
|
||||
Mep,
|
||||
/// Mega Evolution
|
||||
Meg,
|
||||
/// Phantasmal Flames
|
||||
Pfl,
|
||||
}
|
||||
|
||||
impl EditionCode {
|
||||
pub fn edition_num(self) -> &'static str {
|
||||
match self {
|
||||
EditionCode::Ssh => "SWSH1",
|
||||
EditionCode::Svp => "SVP",
|
||||
EditionCode::Svi => "SV01",
|
||||
EditionCode::Pal => "SV02",
|
||||
EditionCode::Obf => "SV03",
|
||||
|
|
@ -78,13 +57,6 @@ impl EditionCode {
|
|||
EditionCode::Scr => "SV07",
|
||||
EditionCode::Ssp => "SV08",
|
||||
EditionCode::Pre => "SV8pt5",
|
||||
EditionCode::Jtg => "SV9",
|
||||
EditionCode::Dri => "SV10",
|
||||
EditionCode::Blk => "SV10pt5ZSV",
|
||||
EditionCode::Wht => "SV10pt5RSV",
|
||||
EditionCode::Mep => "MEP",
|
||||
EditionCode::Meg => "MEG1",
|
||||
EditionCode::Pfl => "MEG2",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +64,6 @@ impl EditionCode {
|
|||
match self {
|
||||
EditionCode::Ssh => "sword-shield",
|
||||
EditionCode::Svi => "scarlet-violet",
|
||||
EditionCode::Svp => "scarlet-violet-promos",
|
||||
EditionCode::Pal => "paldea-evolved",
|
||||
EditionCode::Obf => "obsidian-flames",
|
||||
EditionCode::Mew => "151",
|
||||
|
|
@ -104,12 +75,6 @@ impl EditionCode {
|
|||
EditionCode::Scr => "stellar-crown",
|
||||
EditionCode::Ssp => "surging-sparks",
|
||||
EditionCode::Pre => "prismatic-evolutions",
|
||||
EditionCode::Jtg => "journey-together",
|
||||
EditionCode::Dri => "destined-rivals",
|
||||
EditionCode::Blk | EditionCode::Wht => "black-white",
|
||||
EditionCode::Meg => "mega-evolution",
|
||||
EditionCode::Mep => "mega-evolution-promos",
|
||||
EditionCode::Pfl => "phantasmal-flames",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +82,6 @@ impl EditionCode {
|
|||
match self {
|
||||
EditionCode::Ssh => EditionBlock::Ssh,
|
||||
EditionCode::Svi
|
||||
| EditionCode::Svp
|
||||
| EditionCode::Pal
|
||||
| EditionCode::Obf
|
||||
| EditionCode::Mew
|
||||
|
|
@ -128,12 +92,7 @@ impl EditionCode {
|
|||
| EditionCode::Sfa
|
||||
| EditionCode::Scr
|
||||
| EditionCode::Ssp
|
||||
| EditionCode::Pre
|
||||
| EditionCode::Jtg
|
||||
| EditionCode::Dri
|
||||
| EditionCode::Blk
|
||||
| EditionCode::Wht => EditionBlock::Sv,
|
||||
EditionCode::Meg | EditionCode::Mep | EditionCode::Pfl => EditionBlock::Meg,
|
||||
| EditionCode::Pre => EditionBlock::Sv,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
src/lang.rs
25
src/lang.rs
|
|
@ -2,30 +2,9 @@
|
|||
|
||||
use strum::{Display, EnumString};
|
||||
|
||||
use crate::malie::models::Lang;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Display, EnumString, PartialEq)]
|
||||
#[derive(Clone, Copy, Display, EnumString)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
pub enum Language {
|
||||
De,
|
||||
En,
|
||||
Es,
|
||||
EsLa,
|
||||
It,
|
||||
Fr,
|
||||
Pt,
|
||||
}
|
||||
|
||||
impl From<Language> for Lang {
|
||||
fn from(value: Language) -> Self {
|
||||
match value {
|
||||
Language::De => Lang::De,
|
||||
Language::En => Lang::En,
|
||||
Language::Es => Lang::Es,
|
||||
Language::EsLa => Lang::EsLa,
|
||||
Language::It => Lang::It,
|
||||
Language::Fr => Lang::Fr,
|
||||
Language::Pt => Lang::Pt,
|
||||
}
|
||||
}
|
||||
En,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
use anyhow::{Context, Result};
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
use crate::constants::{APP_NAME, SNAKE_CASE_APP_NAME};
|
||||
use crate::directories::data_directory;
|
||||
|
||||
pub enum LogMode {
|
||||
File,
|
||||
Print,
|
||||
}
|
||||
|
||||
/// Sets up logging for the application.
|
||||
pub async fn initialize_logging(mode: LogMode) -> Result<()> {
|
||||
match mode {
|
||||
LogMode::File => initialize_file_logging().await,
|
||||
LogMode::Print => initialize_print_logging(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn initialize_file_logging() -> Result<()> {
|
||||
let user_directory = data_directory()
|
||||
.await
|
||||
.context("While initializing logging")?;
|
||||
let log_path = user_directory.join(format!("{APP_NAME}.log"));
|
||||
let log_file = std::fs::File::create(log_path)?;
|
||||
let file_subscriber = tracing_subscriber::fmt::layer()
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_writer(log_file)
|
||||
.with_target(false)
|
||||
.with_ansi(false)
|
||||
.with_filter(tracing_subscriber::filter::EnvFilter::from(format!(
|
||||
"{SNAKE_CASE_APP_NAME}=debug"
|
||||
)));
|
||||
tracing_subscriber::registry().with(file_subscriber).init();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initialize_print_logging() -> Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::filter::EnvFilter::from(format!(
|
||||
"{SNAKE_CASE_APP_NAME}=debug"
|
||||
)))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
Ok(())
|
||||
}
|
||||
43
src/main.rs
43
src/main.rs
|
|
@ -1,35 +1,30 @@
|
|||
use anyhow::Result;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
use crate::lang::Language;
|
||||
|
||||
pub mod card;
|
||||
pub mod cli;
|
||||
pub mod constants;
|
||||
pub mod data_store;
|
||||
pub mod directories;
|
||||
pub mod downloader;
|
||||
pub mod editions;
|
||||
pub mod lang;
|
||||
pub mod logging;
|
||||
pub mod malie;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = cli::Args::parse();
|
||||
let log_mode = if args.command == cli::Command::Tui {
|
||||
logging::LogMode::File
|
||||
} else {
|
||||
logging::LogMode::Print
|
||||
};
|
||||
logging::initialize_logging(log_mode).await?;
|
||||
match args.command {
|
||||
cli::Command::DownloadData { lang } => download_data(lang).await?,
|
||||
cli::Command::Tui => todo!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_data(lang: Language) -> Result<()> {
|
||||
let client = malie::client::Client::new().await?;
|
||||
client.download_all_data(lang).await?;
|
||||
let client = reqwest::Client::new();
|
||||
let edition =
|
||||
editions::EditionCode::from_str(&args.code).context("Couldn't parse edition code")?;
|
||||
let number = args.number;
|
||||
let slug = args.slug.as_deref();
|
||||
let card_info = downloader::card_info::download_card_info(
|
||||
client.clone(),
|
||||
lang::Language::Es,
|
||||
edition,
|
||||
number,
|
||||
slug,
|
||||
)
|
||||
.await?;
|
||||
println!("{card_info:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
//! Client to download data from malie.io
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use camino::Utf8PathBuf;
|
||||
use futures::future::try_join_all;
|
||||
use tokio::fs::File;
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_util::io::StreamReader;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::models::{Index, RawIndex};
|
||||
use crate::data_store;
|
||||
use crate::directories::data_cache_directory;
|
||||
use crate::lang::Language;
|
||||
use crate::malie::models::{Lang, filter_invalid_editions};
|
||||
|
||||
/// Client to download data from mallie.io
|
||||
pub struct Client {
|
||||
client: reqwest::Client,
|
||||
data_cache_directory: Utf8PathBuf,
|
||||
}
|
||||
|
||||
const TCGL_BASE_URL: &str = "https://cdn.malie.io/file/malie-io/tcgl/export";
|
||||
|
||||
impl Client {
|
||||
/// Create a new `Client`
|
||||
pub async fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
client: reqwest::Client::new(),
|
||||
data_cache_directory: data_cache_directory().await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn download_all_data(&self, lang: Language) -> Result<()> {
|
||||
let lang: Lang = lang.into();
|
||||
let data_store = data_store::Store::new().await?;
|
||||
self.download_tcgl_index_json().await?;
|
||||
let index = self.load_tcgl_index().await?;
|
||||
data_store.write_index(index.clone()).await?;
|
||||
let edition_downloads = index.into_iter().filter_map(|edition| {
|
||||
if edition.lang == lang {
|
||||
Some(self.download_tcgl_edition_json(edition.path))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
try_join_all(edition_downloads).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn download_tcgl_index_json(&self) -> Result<()> {
|
||||
let file_path = self.data_cache_directory.join("tcgl_index.json");
|
||||
let url = format!("{TCGL_BASE_URL}/index.json");
|
||||
self.download_if_not_exists(file_path, &url).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn download_tcgl_edition_json(&self, url_path: String) -> Result<()> {
|
||||
let file_path = self.data_cache_directory.join(&url_path);
|
||||
let url = format!("{TCGL_BASE_URL}/{url_path}");
|
||||
self.download_if_not_exists(file_path, &url).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_tcgl_index(&self) -> Result<Index> {
|
||||
let file_path = self.data_cache_directory.join("tcgl_index.json");
|
||||
let index = tokio::fs::read_to_string(&file_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read {file_path}"))?;
|
||||
let index: RawIndex =
|
||||
serde_json::from_str(&index).with_context(|| format!("Couldn't parse {file_path}"))?;
|
||||
let index = filter_invalid_editions(index);
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
async fn download_if_not_exists(&self, file_path: Utf8PathBuf, url: &str) -> Result<()> {
|
||||
if let Ok(true) = tokio::fs::try_exists(&file_path).await {
|
||||
debug!("Found {}, skipping download", &file_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(p) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(p).await?;
|
||||
}
|
||||
|
||||
let response = self.client.get(url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Error {} when downloading: {}",
|
||||
response.status(),
|
||||
url
|
||||
));
|
||||
}
|
||||
|
||||
let mut file = File::create_new(&file_path)
|
||||
.await
|
||||
.with_context(|| format!("Couldn't create file {file_path}"))?;
|
||||
tokio::io::copy_buf(
|
||||
&mut StreamReader::new(
|
||||
response
|
||||
.bytes_stream()
|
||||
.map(|result| result.map_err(std::io::Error::other)),
|
||||
),
|
||||
&mut file,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("While writing to file {file_path}"))?;
|
||||
file.sync_all().await?;
|
||||
|
||||
info!("Downloaded {file_path} from {url}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
//! Module to interact with the PTCG data from malie.io
|
||||
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
//! Models for malie.io exports
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, de};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::editions::EditionCode;
|
||||
|
||||
pub type RawIndex = HashMap<Lang, HashMap<String, RawEdition>>;
|
||||
pub type Index = Vec<Edition>;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Hash)]
|
||||
pub enum Lang {
|
||||
#[serde(rename = "de-DE")]
|
||||
De,
|
||||
#[serde(rename = "en-US")]
|
||||
En,
|
||||
#[serde(rename = "es-ES")]
|
||||
Es,
|
||||
#[serde(rename = "es-419")]
|
||||
EsLa,
|
||||
#[serde(rename = "it-IT")]
|
||||
It,
|
||||
#[serde(rename = "fr-FR")]
|
||||
Fr,
|
||||
#[serde(rename = "pt-BR")]
|
||||
Pt,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RawEdition {
|
||||
path: String,
|
||||
#[serde(deserialize_with = "deserialize_edition_code")]
|
||||
abbr: Option<EditionCode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Edition {
|
||||
pub lang: Lang,
|
||||
pub path: String,
|
||||
pub abbr: EditionCode,
|
||||
}
|
||||
|
||||
fn deserialize_edition_code<'de, D>(deserializer: D) -> Result<Option<EditionCode>, D::Error>
|
||||
where
|
||||
D: de::Deserializer<'de>,
|
||||
{
|
||||
let buf = Cow::<'de, str>::deserialize(deserializer)?;
|
||||
|
||||
if buf.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let result = serde_json::from_str::<EditionCode>(&format!("\"{buf}\""))
|
||||
.with_context(|| format!("couldn't deserialize edition code {buf}"))
|
||||
.inspect_err(|e| warn!("{e}"));
|
||||
Ok(result.ok())
|
||||
}
|
||||
|
||||
pub fn filter_invalid_editions(index: RawIndex) -> Index {
|
||||
index
|
||||
.into_iter()
|
||||
.flat_map(|(lang, v)| {
|
||||
v.into_values().filter_map(move |e| match e.abbr {
|
||||
Some(abbr) => Some(Edition {
|
||||
path: e.path,
|
||||
abbr,
|
||||
lang,
|
||||
}),
|
||||
None => None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue