download index

Signed-off-by: Felipe Contreras Salinas <felipe@bstr.cl>
This commit is contained in:
Felipe 2025-12-27 01:48:17 -03:00
parent ed05e287b7
commit 7bd717e4db
Signed by: pitbuster
SSH key fingerprint: SHA256:HDYu2Pm4/TmSX8GBwV49UvFWr1Ljg8XlHxKeCpjJpOk
14 changed files with 301 additions and 338 deletions

52
Cargo.lock generated
View file

@ -985,6 +985,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@ -1056,8 +1065,11 @@ dependencies = [
"fluent-templates",
"reqwest",
"serde",
"serde_json",
"strum",
"tokio",
"tokio-stream",
"tokio-util",
"tracing",
"tracing-subscriber",
]
@ -1394,14 +1406,15 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.140"
version = "1.0.147"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
checksum = "6af14725505314343e673e9ecb7cd7e8a36aa9791eb936235a3567cc31447ae4"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@ -1653,11 +1666,22 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.14"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034"
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
dependencies = [
"bytes",
"futures-core",
@ -1749,12 +1773,13 @@ dependencies = [
]
[[package]]
name = "tracing-serde"
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"serde",
"log",
"once_cell",
"tracing-core",
]
@ -1765,15 +1790,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"serde",
"serde_json",
"sharded-slab",
"thread_local",
"tracing",
"tracing-core",
"tracing-serde",
"tracing-log",
]
[[package]]
@ -2322,3 +2346,9 @@ dependencies = [
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4af59da1029247450b54ba43e0b62c8e376582464bbe5504dd525fe521e7e8fd"

View file

@ -21,12 +21,20 @@ serde = { version = "1.0.228", default-features = false, features = [
"derive",
"std",
] }
serde_json = "1.0.147"
strum = { version = "0.27.2", features = ["derive"] }
tokio = { version = "1.48.0", default-features = false, features = ["fs", "rt-multi-thread", "macros"] }
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 = [
"fmt",
"json",
"ansi",
"env-filter",
"fmt",
"tracing",
"tracing-log",
] }

View file

@ -1,45 +0,0 @@
//! 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 {},
}

View file

@ -1,15 +1,18 @@
//! CLI parameters
use clap::Parser;
use clap::{Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
pub struct Args {
/// 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>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand, PartialEq)]
pub enum Command {
/// Downloads the card data
DownloadData,
/// Terminal User Interface
Tui,
}

4
src/constants.rs Normal file
View file

@ -0,0 +1,4 @@
//! Application wide constants.
pub const APP_NAME: &str = "ptcg-tools";
pub const SNAKE_CASE_APP_NAME: &str = "ptcg_tools";

47
src/directories.rs Normal file
View file

@ -0,0 +1,47 @@
//! 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)
}

View file

@ -1,231 +0,0 @@
//! 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")
}

View file

@ -1,3 +0,0 @@
//! Data downloaders
pub mod card_info;

View file

@ -1,5 +1,6 @@
//! Editions information
use serde::Deserialize;
use strum::{Display, EnumString};
pub enum EditionBlock {
@ -8,10 +9,12 @@ pub enum EditionBlock {
Sm,
Ssh,
Sv,
Meg,
}
#[derive(Clone, Copy, Display, Debug, Hash, PartialEq, Eq, EnumString)]
#[derive(Clone, Copy, Display, Debug, Hash, PartialEq, Eq, EnumString, Deserialize)]
#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
#[serde(rename_all = "UPPERCASE")]
pub enum EditionCode {
/// Sword and Shield
Ssh,
@ -39,6 +42,10 @@ pub enum EditionCode {
Ssp,
/// Prismatic Evolutions
Pre,
/// Mega Evolution
Meg,
/// Phantasmal Flames
Pfl,
}
impl EditionCode {
@ -57,6 +64,8 @@ impl EditionCode {
EditionCode::Scr => "SV07",
EditionCode::Ssp => "SV08",
EditionCode::Pre => "SV8pt5",
EditionCode::Meg => "MEG1",
EditionCode::Pfl => "MEG2",
}
}
@ -75,6 +84,8 @@ impl EditionCode {
EditionCode::Scr => "stellar-crown",
EditionCode::Ssp => "surging-sparks",
EditionCode::Pre => "prismatic-evolutions",
EditionCode::Meg => "mega-evolution",
EditionCode::Pfl => "phantasmal-flames",
}
}
@ -93,6 +104,7 @@ impl EditionCode {
| EditionCode::Scr
| EditionCode::Ssp
| EditionCode::Pre => EditionBlock::Sv,
EditionCode::Meg | EditionCode::Pfl => EditionBlock::Meg,
}
}
}

View file

@ -1,21 +1,28 @@
use anyhow::{Result, anyhow};
use camino::Utf8PathBuf;
use directories::ProjectDirs;
use anyhow::{Context, Result};
use tracing_subscriber::Layer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
const APP_NAME: &str = "ptcg-tools";
use crate::constants::{APP_NAME, SNAKE_CASE_APP_NAME};
use crate::directories::data_directory;
/// Sets up logging for the application. Since the mode we will need logging for is a TUI, we can't
/// print our logs into stdout, so we will log into a file instead.
pub async fn initialize_logging() -> Result<()> {
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?;
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()
@ -25,8 +32,18 @@ pub async fn initialize_logging() -> Result<()> {
.with_target(false)
.with_ansi(false)
.with_filter(tracing_subscriber::filter::EnvFilter::from(format!(
"{APP_NAME}=debug"
"{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(())
}

View file

@ -1,11 +1,9 @@
use std::str::FromStr;
use anyhow::{Context, Result};
use anyhow::Result;
use clap::Parser;
pub mod card;
pub mod cli;
pub mod downloader;
pub mod constants;
pub mod directories;
pub mod editions;
pub mod lang;
pub mod logging;
@ -14,20 +12,21 @@ pub mod malie;
#[tokio::main]
async fn main() -> Result<()> {
let args = cli::Args::parse();
logging::initialize_logging().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:?}");
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 => download_data().await?,
cli::Command::Tui => todo!(),
}
Ok(())
}
async fn download_data() -> Result<()> {
let client = malie::client::Client::new().await?;
client.download_all_data().await?;
Ok(())
}

86
src/malie/client.rs Normal file
View file

@ -0,0 +1,86 @@
//! Client to download data from malie.io
use anyhow::{Context, Result, anyhow};
use camino::Utf8PathBuf;
use tokio::fs::File;
use tokio_stream::StreamExt;
use tokio_util::io::StreamReader;
use tracing::debug;
use super::models::Index;
use crate::directories::data_cache_directory;
/// 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) -> Result<()> {
self.download_tcgl_index_json().await?;
let index = self.load_tcgl_index().await?;
println!("{index:?}");
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(())
}
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: Index =
serde_json::from_str(&index).with_context(|| format!("Couldn't parse {file_path}"))?;
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(());
}
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?;
Ok(())
}
}

View file

@ -1 +1,4 @@
//! Module to interact with the PTCG data from malie.io
pub mod client;
pub mod models;

33
src/malie/models.rs Normal file
View file

@ -0,0 +1,33 @@
//! Models for malie.io exports
use std::collections::HashMap;
use serde::Deserialize;
use crate::editions::EditionCode;
pub type Index = HashMap<Lang, HashMap<String, Edition>>;
#[derive(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(Debug, Deserialize)]
pub struct Edition {
path: String,
abbr: Option<EditionCode>,
}