Added documentation

Added test cases
Added comments
This commit is contained in:
Jan-Peter von Hunnius 2025-10-02 17:35:56 +02:00
parent 7a8d9beedc
commit 32c4d3c10e
10 changed files with 3852 additions and 63 deletions

View file

@ -1,4 +1,29 @@
use std::collections::{HashMap};
//! # Top Drives Club Scraper
//!
//! Alternative data source for the Top Drives Records Analyzer that scrapes car
//! performance data from the topdrives.club website. This provides an alternative
//! to the JSON-based records system for fetching best car information.
//!
//! ## Purpose
//!
//! While the main `records.rs` module uses pre-downloaded JSON timing files,
//! this module can fetch fresh data directly from the topdrives.club website
//! by scraping HTML tables and parsing car information.
//!
//! ## Features
//!
//! - Scrapes best car data from topdrives.club
//! - Supports track-specific car recommendations
//! - Configurable RQ ranges and track filtering
//! - HTML parsing with fallback error handling
//!
//! ## Dependencies
//!
//! - `reqwest`: HTTP client for web scraping
//! - `scraper`: HTML parsing and CSS selector library
//! - `tokio`: Asynchronous runtime for web requests
use std::collections::HashMap;
use std::env;
use std::fs::read_to_string;
use std::str::FromStr;
@ -7,14 +32,48 @@ use tokio;
use scraper::{Html, Selector};
/// Represents a car entry scraped from the topdrives.club website
///
/// Contains the essential car information needed for performance analysis,
/// parsed from the HTML tables on the topdrives.club best cars pages.
struct Car {
/// Car class letter (A, B, C, D, E, S)
class: String,
/// Rarity level (10-113)
rq: u8,
/// Car manufacturer/brand name
manufacturer: String,
/// Car model name
model: String,
/// Car model year
year: u16,
/// Upgrade configuration string (e.g., "333", "323")
upgrade: String,
}
/// Fetches HTML content from a URL with error handling
///
/// Downloads the complete HTML content from the specified URL. Uses a custom
/// HTTP client that accepts invalid SSL certificates to handle potential
/// certificate issues with the target website.
///
/// # Arguments
///
/// * `url` - The URL to fetch HTML content from
///
/// # Returns
///
/// Returns the HTML content as a String, or an empty string if the request fails.
/// Failures are logged to the console but don't cause the program to panic.
///
/// # Security Note
///
/// Uses `danger_accept_invalid_certs(true)` to handle websites with certificate
/// issues. This should only be used with trusted sources like topdrives.club.
///
/// # Error Handling
///
/// Returns empty string on any network or parsing error, allowing the calling
/// code to handle the absence of data gracefully.
async fn get_page(url: &str) -> String {
match reqwest::Client::builder()
.danger_accept_invalid_certs(true)
@ -38,7 +97,236 @@ async fn get_page(url: &str) -> String {
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Creates mock HTML for testing car parsing
fn create_mock_car_html() -> String {
r#"
<div class="carList__item-manufacturer">Ferrari</div>
<div class="carList__item-model">F40</div>
<div class="carList__item-year">1987</div>
<div class="carList__item-level">
<span>S</span>
<span>95</span>
</div>
<div class="carList__item-upgrade">323</div>
"#
.to_string()
}
/// Creates mock HTML with missing elements
fn create_incomplete_car_html() -> String {
r#"
<div class="carList__item-manufacturer">McLaren</div>
<div class="carList__item-model">F1</div>
<div class="carList__item-year">1993</div>
"#
.to_string()
}
/// Creates mock tracks file content
fn create_mock_tracks_content() -> String {
"1 Laguna Seca\n2 Monaco Streets\n3 Test Bowl\n".to_string()
}
#[test]
fn test_parse_best_car_complete() {
let html = create_mock_car_html();
let car = parse_best_car(html);
assert_eq!(car.manufacturer, "Ferrari");
assert_eq!(car.model, "F40");
assert_eq!(car.year, 1987);
assert_eq!(car.class, "S");
assert_eq!(car.rq, 95);
assert_eq!(car.upgrade, "323");
}
#[test]
fn test_parse_best_car_incomplete() {
let html = create_incomplete_car_html();
let car = parse_best_car(html);
// Should use default values for missing fields
assert_eq!(car.manufacturer, "McLaren");
assert_eq!(car.model, "F1");
assert_eq!(car.year, 1993);
assert_eq!(car.class, "Z"); // Default value
assert_eq!(car.rq, 0); // Default value
assert_eq!(car.upgrade, "000"); // Default value
}
#[test]
fn test_parse_best_car_empty() {
let html = String::new();
let car = parse_best_car(html);
// Should use all default values
assert_eq!(car.class, "Z");
assert_eq!(car.rq, 0);
assert_eq!(car.manufacturer, "");
assert_eq!(car.model, "");
assert_eq!(car.year, 1900);
assert_eq!(car.upgrade, "000");
}
#[test]
fn test_parse_tracks() {
let content = create_mock_tracks_content();
let tracks = parse_tracks_from_string(content);
assert_eq!(tracks.len(), 3);
assert_eq!(tracks.get(&1), Some(&"Laguna Seca".to_string()));
assert_eq!(tracks.get(&2), Some(&"Monaco Streets".to_string()));
assert_eq!(tracks.get(&3), Some(&"Test Bowl".to_string()));
assert_eq!(tracks.get(&99), None);
}
#[test]
fn test_parse_tracks_empty() {
let content = String::new();
let tracks = parse_tracks_from_string(content);
assert_eq!(tracks.len(), 0);
}
#[test]
fn test_parse_argument_valid() {
let args = vec!["program".to_string(), "85".to_string()];
let result = parse_argument(&args, 1, 10, 113);
assert_eq!(result, 85);
}
#[test]
#[should_panic(expected = "Argument 1 is out of range")]
fn test_parse_argument_too_low() {
let args = vec!["program".to_string(), "5".to_string()];
parse_argument(&args, 1, 10, 113);
}
#[test]
#[should_panic(expected = "Argument 1 is out of range")]
fn test_parse_argument_too_high() {
let args = vec!["program".to_string(), "150".to_string()];
parse_argument(&args, 1, 10, 113);
}
#[test]
#[should_panic(expected = "Argument 1 could not be parsed")]
fn test_parse_argument_invalid() {
let args = vec!["program".to_string(), "invalid".to_string()];
parse_argument(&args, 1, 10, 113);
}
#[test]
fn test_parse_argument_boundary_values() {
let args_low = vec!["program".to_string(), "10".to_string()];
let result_low = parse_argument(&args_low, 1, 10, 113);
assert_eq!(result_low, 10);
let args_high = vec!["program".to_string(), "113".to_string()];
let result_high = parse_argument(&args_high, 1, 10, 113);
assert_eq!(result_high, 113);
}
#[tokio::test]
async fn test_get_best_car_url_construction() {
// Test that URL construction follows expected pattern
// Note: We can't easily test the actual HTTP request without mocking
let track = 1;
let track_name = "Laguna Seca".to_string();
let max_level = 85;
let detailed = false;
// This would be the expected URL format (for documentation purposes)
let _expected_url_pattern = "https://topdrives.club/ajax/load-best-vehicles";
// In a real test environment, we'd mock the HTTP request
// For now, we verify the function doesn't panic with valid inputs
let result = get_best_car(track, track_name, max_level, detailed).await;
// Result may be empty due to network requirements, but shouldn't panic
assert!(result.is_empty() || result.len() > 0);
}
#[test]
fn test_car_struct_creation() {
let car = Car {
class: "S".to_string(),
rq: 95,
manufacturer: "Ferrari".to_string(),
model: "F40".to_string(),
year: 1987,
upgrade: "323".to_string(),
};
assert_eq!(car.class, "S");
assert_eq!(car.rq, 95);
assert_eq!(car.manufacturer, "Ferrari");
assert_eq!(car.model, "F40");
assert_eq!(car.year, 1987);
assert_eq!(car.upgrade, "323");
}
#[test]
fn test_parse_tracks_malformed_lines() {
// Test handling of malformed track file lines
let content = "1 Laguna Seca\ninvalid line\n2 Monaco Streets\n".to_string();
let tracks = parse_tracks_from_string(content);
// Should only parse valid lines
assert_eq!(tracks.len(), 2);
assert_eq!(tracks.get(&1), Some(&"Laguna Seca".to_string()));
assert_eq!(tracks.get(&2), Some(&"Monaco Streets".to_string()));
}
/// Helper function to test parse_tracks without file I/O
fn parse_tracks_from_string(content: String) -> HashMap<u16, String> {
let mut result: HashMap<u16, String> = HashMap::new();
for line in content.lines() {
if let Some((index, track)) = line.split_once(' ') {
if let Ok(id) = index.parse::<u16>() {
result.insert(id, String::from(track));
}
}
}
result
}
}
/// Parses HTML content to extract the best car information
///
/// Uses CSS selectors to extract car details from the HTML table structure
/// used by topdrives.club. The function parses manufacturer, model, year,
/// class, RQ level, and upgrade configuration from the page content.
///
/// # Arguments
///
/// * `text` - The HTML content containing the car information table
///
/// # Returns
///
/// Returns a `Car` struct populated with the parsed information. If parsing
/// fails at any step, default values are used to prevent crashes.
///
/// # Parsing Strategy
///
/// 1. **Manufacturer**: Extracts from `.carList__item-manufacturer` element
/// 2. **Model**: Extracts from `.carList__item-model` element
/// 3. **Year**: Extracts from `.carList__item-year` element
/// 4. **Class & RQ**: Complex parsing from `.carList__item-level` element
/// - Inner HTML contains both class letter and RQ number
/// - Uses nested HTML parsing to separate the two values
/// 5. **Upgrade**: Extracts from `.carList__item-upgrade` element
///
/// # Error Handling
///
/// Uses `unwrap_or` defaults for all fields to ensure the function never
/// panics due to malformed HTML or missing elements.
fn parse_best_car(text: String) -> Car {
// Initialize with default values in case parsing fails
let mut car = Car {
class: String::from("Z"),
rq: 0,
@ -50,18 +338,21 @@ fn parse_best_car(text: String) -> Car {
let document = Html::parse_document(&text);
// Extract car manufacturer name
let manufacturer_selector = Selector::parse(".carList__item-manufacturer").unwrap();
car.manufacturer = String::from(match document.select(&manufacturer_selector).next() {
None => "",
Some(manufacturer) => manufacturer.text().collect::<Vec<_>>()[0],
});
// Extract car model name
let model_selector = Selector::parse(".carList__item-model").unwrap();
car.model = String::from(match document.select(&model_selector).next() {
None => "",
Some(model) => model.text().collect::<Vec<_>>()[0],
});
// Extract car model year
let year_selector = Selector::parse(".carList__item-year").unwrap();
car.year = u16::from_str(match document.select(&year_selector).next() {
None => "1900",
@ -69,14 +360,21 @@ fn parse_best_car(text: String) -> Car {
})
.unwrap();
// Parse RQ level and class (more complex as they're in the same element)
let level_selector = Selector::parse(".carList__item-level").unwrap();
let rq_raw = document
.select(&level_selector)
.next()
.unwrap()
.inner_html();
let rq_raw = match document.select(&level_selector).next() {
Some(element) => element.inner_html(),
None => {
// If level element is missing, use defaults
car.class = "Z".to_string();
car.rq = 0;
return car;
}
};
let rq_fragment = Html::parse_document(rq_raw.trim());
// Extract class letter and RQ number from nested spans
let span_selector = Selector::parse("span").unwrap();
let mut element_iterator = rq_fragment.select(&span_selector);
let class = String::from(match element_iterator.next() {
@ -90,6 +388,7 @@ fn parse_best_car(text: String) -> Car {
};
car.rq = u8::from_str(rq).unwrap();
// Extract upgrade configuration
let upgrade_selector = Selector::parse(".carList__item-upgrade ").unwrap();
let upgrade_raw = match document.select(&upgrade_selector).next() {
None => "",
@ -100,9 +399,37 @@ fn parse_best_car(text: String) -> Car {
car
}
/// Parses the tracks definition file to create track ID to name mappings
///
/// Reads a text file containing track definitions where each line consists
/// of a track ID number followed by the track name. Creates a HashMap for
/// efficient track ID to name lookups.
///
/// # Arguments
///
/// * `file_name` - Path to the tracks definition file
///
/// # Returns
///
/// Returns a `HashMap<u16, String>` mapping track IDs to track names.
///
/// # File Format
///
/// The tracks file should have one track per line in the format:
/// ```text
/// 1 Laguna Seca
/// 2 Monaco Streets
/// 3 Test Bowl
/// ```
///
/// # Error Handling
///
/// Uses `unwrap()` for simplicity - in production, proper error handling
/// should be implemented for file I/O and parsing errors.
fn parse_tracks(file_name: &str) -> HashMap<u16, String> {
let mut result: HashMap<u16, String> = HashMap::new();
// Process each line in the tracks file
for line in read_to_string(file_name).unwrap().lines() {
let (index, track) = line.split_once(' ').unwrap();
result.insert(index.parse::<u16>().unwrap(), String::from(track));
@ -111,6 +438,33 @@ fn parse_tracks(file_name: &str) -> HashMap<u16, String> {
result
}
/// Main entry point for the Top Drives Club scraper
///
/// Scrapes best car data from topdrives.club for specified tracks and RQ ranges.
/// Provides an alternative data source to the JSON-based records system.
///
/// # Command Line Arguments
///
/// - No args: Process all tracks with default RQ range (10-113)
/// - 1 arg: Set maximum RQ level for all tracks
/// - 2 args: Set max RQ and process single track
/// - 3 args: Set max RQ, start track, and end track for range processing
/// - `-d` flag: Enable detailed output format
///
/// # Arguments
///
/// * `max_rq` - Maximum RQ level to consider (default: 113)
/// * `min_track` - First track ID to process (default: 4)
/// * `max_track` - Last track ID to process (default: 278)
/// * `detailed` - Whether to show detailed output with upgrade info
///
/// # Example Usage
///
/// ```bash
/// cargo run --bin club # All tracks, default RQ
/// cargo run --bin club -d 100 # All tracks, RQ <= 100, detailed
/// cargo run --bin club 85 1 50 # Tracks 1-50, RQ <= 85
/// ```
#[tokio::main]
async fn main() {
let mut args: Vec<_> = env::args().collect();
@ -163,7 +517,10 @@ async fn main() {
let best_car = get_best_car(track, track_name.clone(), level, detailed).await;
if best_car.len() > 10 {
// println!("Best Car under RQ {} for track {} ({}): {}", level, track, track_name, best_car);
keepers.entry(best_car).and_modify(|counter| { *counter += 1 } ).or_insert(1);
keepers
.entry(best_car)
.and_modify(|counter| *counter += 1)
.or_insert(1);
}
}
}
@ -173,11 +530,37 @@ async fn main() {
for car in keepers {
match detailed {
true => println!("{}", car.0),
false => println!("{} {}", car.0, car.1)
false => println!("{} {}", car.0, car.1),
}
}
}
/// Parses and validates command line arguments with range checking
///
/// Validates that numeric arguments are within acceptable ranges and provides
/// clear error messages for invalid input. Used for RQ levels and track IDs.
///
/// # Arguments
///
/// * `args` - The command line arguments vector
/// * `index` - Index of the argument to parse
/// * `min` - Minimum acceptable value
/// * `max` - Maximum acceptable value
///
/// # Returns
///
/// Returns the parsed and validated value
///
/// # Panics
///
/// Panics with descriptive error messages if:
/// - The argument cannot be parsed as a number
/// - The value is outside the acceptable range
///
/// # Error Messages
///
/// Provides clear feedback about expected value ranges and which argument
/// position is problematic.
fn parse_argument(args: &Vec<String>, index: usize, min: u16, max: u16) -> u16 {
match args[index].parse::<u16>() {
Ok(parsed_value) => {
@ -196,11 +579,54 @@ fn parse_argument(args: &Vec<String>, index: usize, min: u16, max: u16) -> u16 {
}
}
/// Fetches and parses the best car data for a specific track from topdrives.club
///
/// Constructs the appropriate URL for the topdrives.club API, fetches the HTML
/// response, and parses it to extract the best car information for the given
/// track and RQ constraints.
///
/// # Arguments
///
/// * `track` - The track ID number (1-278)
/// * `track_name` - Human-readable track name for display
/// * `max_level` - Maximum RQ level to consider
/// * `detailed` - Whether to include upgrade configuration in output
///
/// # Returns
///
/// Returns a formatted string containing the best car information, or an empty
/// string if the request fails or no data is available.
///
/// # API Details
///
/// The function constructs a URL with the following parameters:
/// - `sort_by=level&sortorder=desc`: Sort by RQ level, highest first
/// - `prize=all`: Include all cars, not just prize cars
/// - `level=10%3B{max_level}`: RQ range from 10 to max_level
/// - `acceleration=1%3B50&grip=40%3B110`: Performance filters
/// - `year=1900%3B2030`: Year range filter
/// - `seats_count=1%3B9`: Include all seat counts
/// - `track={track}`: Specific track ID
/// - `page=0`: First page of results
///
/// # Output Formats
///
/// **Detailed mode:**
/// ```text
/// S85 Ferrari 250 GTO 1962: Laguna Seca (333)
/// ```
///
/// **Normal mode:**
/// ```text
/// S85 Ferrari 250 GTO 1962
/// ```
async fn get_best_car(track: u16, track_name: String, max_level: u16, detailed: bool) -> String {
// Construct the API URL with all necessary parameters
let url = format!("https://topdrives.club/ajax/load-best-vehicles?sort_by=level&sortorder=desc&prize=all&level=10%3B{}&acceleration=1%3B50&grip=40%3B110&year=1900%3B2030&seats_count=1%3B9&track={}&page=0",
max_level, track);
max_level, track);
// println!("Getting {}", url);
let text = get_page(&url).await;
if text.len() > 10 {
let best_car = parse_best_car(text);
@ -208,27 +634,28 @@ async fn get_best_car(track: u16, track_name: String, max_level: u16, detailed:
true => {
format!(
"{}{} {} {} {}: {} ({})",
best_car.class,
best_car.rq,
best_car.manufacturer,
best_car.model,
best_car.year,
track_name,
best_car.upgrade,
best_car.class, // Car class (S, A, B, etc.)
best_car.rq, // RQ level
best_car.manufacturer, // Brand name
best_car.model, // Model name
best_car.year, // Model year
track_name, // Track name
best_car.upgrade, // Upgrade configuration
)
}
false => {
format!(
"{}{} {} {} {}",
best_car.class,
best_car.rq,
best_car.manufacturer,
best_car.model,
best_car.year,
best_car.class, // Car class
best_car.rq, // RQ level
best_car.manufacturer, // Brand name
best_car.model, // Model name
best_car.year, // Model year
)
}
}
} else {
// No data received or request failed
String::from("")
}
}

File diff suppressed because it is too large Load diff