Skip to main content

chaser_util/
vs_result.rs

1//! CHaser Online battle result scraper.
2//!
3//! # Quick start
4//!
5//! ```no_run
6//! use chaser_util::vs_result::{fetch_vs_result, VsResultQuery};
7//!
8//! #[tokio::main]
9//! async fn main() {
10//!     let query = VsResultQuery::today();   // FIX: was a hardcoded date
11//!     let results = fetch_vs_result("cool", "cool", query, None).await.unwrap();
12//!     for r in &results {
13//!         println!("room={} {} vs {} -> {} : {}",
14//!             r.room,
15//!             r.players[0].username, r.players[1].username,
16//!             r.players[0].total_point, r.players[1].total_point,
17//!         );
18//!     }
19//! }
20//! ```
21
22use encoding_rs::SHIFT_JIS;
23
24use crate::proxy::{send_follow_redirects, send_once, url_encode, BoxError, ProxyMode};
25
26// ----------------------------------------------------------------
27// Public data types
28// ----------------------------------------------------------------
29
30/// One player's result within a battle.
31#[derive(Debug, Clone, Default)]
32pub struct PlayerResult {
33    pub order:        u32,
34    pub username:     String,
35    pub get_turn:     i32,
36    pub rem_turn:     i32,
37    pub total_point:  i32,
38    pub action_point: Option<i32>,
39    pub item_point:   Option<i32>,
40    pub put_point:    Option<i32>,
41    pub put_damage:   Option<i32>,
42}
43
44/// One battle result (one room, one session).
45#[derive(Debug, Clone)]
46pub struct BattleResult {
47    pub room:       u32,
48    pub start_time: String,
49    pub end_time:   String,
50    pub players:    Vec<PlayerResult>,
51}
52
53/// Query parameters for listvsresult.
54#[derive(Debug, Clone)]
55pub struct VsResultQuery {
56    pub min_start_date:   String,
57    pub min_end_date:     String,
58    pub min_start_time:   String,
59    pub min_end_time:     String,
60    pub min_room:         u32,
61    pub min_turn:         u32,
62    pub max_start_date:   String,
63    pub max_end_date:     String,
64    pub max_start_time:   String,
65    pub max_end_time:     String,
66    pub max_room:         u32,
67    pub max_turn:         u32,
68    pub min_action_point: i32,
69    pub min_item_point:   i32,
70    pub min_put_point:    i32,
71    pub min_put_damage:   i32,
72    pub min_total_point:  i32,
73    pub min_rem_turn:     i32,
74    pub min_get_turn:     i32,
75    pub max_action_point: i32,
76    pub max_item_point:   i32,
77    pub max_put_point:    i32,
78    pub max_put_damage:   i32,
79    pub max_total_point:  i32,
80    pub max_rem_turn:     i32,
81    pub max_get_turn:     i32,
82}
83
84impl VsResultQuery {
85    /// Create a query covering today's results (date computed at runtime).
86    ///
87    /// FIX: Previously `Default::default()` used a hardcoded date string
88    /// ("2026-04-07") that needed manual updating every day.  This method
89    /// calls `chrono::Local::now()` so it is always correct.
90    pub fn today() -> Self {
91        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
92        Self::for_date(&today)
93    }
94
95    /// Create a query covering a specific date (format: "YYYY-MM-DD").
96    pub fn for_date(date: &str) -> Self {
97        Self {
98            min_start_date:   date.to_string(),
99            min_end_date:     date.to_string(),
100            min_start_time:   "00:00:00".to_string(),
101            min_end_time:     "00:00:00".to_string(),
102            min_room:         1,
103            min_turn:         1,
104            max_start_date:   date.to_string(),
105            max_end_date:     date.to_string(),
106            max_start_time:   "23:59:59".to_string(),
107            max_end_time:     "23:59:59".to_string(),
108            max_room:         10000,
109            max_turn:         8,
110            min_action_point: -1_000_000,
111            min_item_point:   -1_000_000,
112            min_put_point:    0,
113            min_put_damage:   -20_000_000,
114            min_total_point:  -30_000_000,
115            min_rem_turn:     -10_000,
116            min_get_turn:     0,
117            max_action_point: 1_000_000,
118            max_item_point:   1_000_000,
119            max_put_point:    20_000_000,
120            max_put_damage:   0,
121            max_total_point:  20_000_000,
122            max_rem_turn:     10_000,
123            max_get_turn:     10_000,
124        }
125    }
126
127    /// Encode into a query string for listvsresult.
128    /// Time values are percent-encoded via `url_encode` (replaces the old
129    /// hand-rolled `encode()` that only escaped `:` and ` `).
130    fn to_query_string(&self) -> String {
131        format!(
132            "minStartDate={}&minEndDate={}&minStartTime={}&minEndTime={}\
133             &minRoomNumber={}&minTurnNumber={}\
134             &maxStartDate={}&maxEndDate={}&maxStartTime={}&maxEndTime={}\
135             &maxRoomNumber={}&maxTurnNumber={}\
136             &minActionPoint={}&minItemPoint={}&minPutPoint={}&minPutDamage={}\
137             &minTotalPoint={}&minRemTurn={}&minGetTurn={}\
138             &maxActionPoint={}&maxItemPoint={}&maxPutPoint={}&maxPutDamage={}\
139             &maxTotalPoint={}&maxRemTurn={}&maxGetTurn={}",
140            url_encode(&self.min_start_date), url_encode(&self.min_end_date),
141            url_encode(&self.min_start_time), url_encode(&self.min_end_time),
142            self.min_room, self.min_turn,
143            url_encode(&self.max_start_date), url_encode(&self.max_end_date),
144            url_encode(&self.max_start_time), url_encode(&self.max_end_time),
145            self.max_room, self.max_turn,
146            self.min_action_point, self.min_item_point,
147            self.min_put_point, self.min_put_damage,
148            self.min_total_point, self.min_rem_turn, self.min_get_turn,
149            self.max_action_point, self.max_item_point,
150            self.max_put_point, self.max_put_damage,
151            self.max_total_point, self.max_rem_turn, self.max_get_turn,
152        )
153    }
154}
155
156/// `Default` now delegates to `VsResultQuery::today()` instead of using a
157/// hardcoded date, so existing code calling `VsResultQuery::default()` still
158/// compiles and works correctly.
159impl Default for VsResultQuery {
160    fn default() -> Self {
161        Self::today()
162    }
163}
164
165// ----------------------------------------------------------------
166// HTML parsing helpers
167// ----------------------------------------------------------------
168
169fn cell_text(s: &str) -> String {
170    s.trim()
171     .replace('\u{3000}', "")
172     .replace('\u{00a0}', "")
173     .trim()
174     .to_string()
175}
176
177fn parse_opt_i32(s: &str) -> Option<i32> {
178    let t = cell_text(s);
179    if t.is_empty() { None } else { t.parse().ok() }
180}
181
182fn parse_i32(s: &str) -> i32 {
183    cell_text(s).parse().unwrap_or(0)
184}
185
186fn parse_u32(s: &str) -> u32 {
187    cell_text(s).parse().unwrap_or(0)
188}
189
190fn parse_results(html: &str) -> Vec<BattleResult> {
191    let dom = match tl::parse(html, tl::ParserOptions::default()) {
192        Ok(d)  => d,
193        Err(_) => return vec![],
194    };
195    let parser = dom.parser();
196
197    let table = match dom
198        .query_selector(r#"table[border="1"]"#)
199        .and_then(|mut q| q.next())
200        .and_then(|h| h.get(parser))
201    {
202        Some(tl::Node::Tag(t)) => t,
203        _ => return vec![],
204    };
205
206    let tr_htmls: Vec<String> = table
207        .children().top().iter()
208        .filter_map(|h| h.get(parser))
209        .filter_map(|n| match n {
210            tl::Node::Tag(t) if t.name().as_bytes().eq_ignore_ascii_case(b"tbody") => {
211                Some(
212                    t.children().top().iter()
213                        .filter_map(|h| h.get(parser))
214                        .filter_map(|n| match n {
215                            tl::Node::Tag(tr)
216                                if tr.name().as_bytes().eq_ignore_ascii_case(b"tr") =>
217                                    Some(tr.outer_html(parser).to_string()),
218                            _ => None,
219                        })
220                        .collect::<Vec<_>>(),
221                )
222            }
223            tl::Node::Tag(t) if t.name().as_bytes().eq_ignore_ascii_case(b"tr") => {
224                Some(vec![t.outer_html(parser).to_string()])
225            }
226            _ => None,
227        })
228        .flatten()
229        .collect();
230
231    let mut results: Vec<BattleResult> = Vec::new();
232    let mut skip_header = true;
233
234    for tr_html in &tr_htmls {
235        let tr_dom = match tl::parse(tr_html, tl::ParserOptions::default()) {
236            Ok(d)  => d,
237            Err(_) => continue,
238        };
239        let tp = tr_dom.parser();
240
241        let cells: Vec<String> = tr_dom
242            .query_selector("td").into_iter().flatten()
243            .filter_map(|h| h.get(tp))
244            .map(|n| {
245                fn text<'a>(node: &tl::Node<'a>, p: &'a tl::Parser<'a>) -> String {
246                    match node {
247                        tl::Node::Raw(b) => b.as_utf8_str().into_owned(),
248                        tl::Node::Tag(t) => t.children().top().iter()
249                            .filter_map(|h| h.get(p))
250                            .map(|n| text(n, p))
251                            .collect(),
252                        _ => String::new(),
253                    }
254                }
255                text(n, tp)
256            })
257            .collect();
258
259        if cells.len() < 12 {
260            continue;
261        }
262
263        if skip_header {
264            skip_header = false;
265            continue;
266        }
267
268        let start_time_text = cell_text(&cells[1]);
269        let is_new_battle   = !start_time_text.is_empty();
270
271        if is_new_battle {
272            let player = PlayerResult {
273                order:        parse_u32(&cells[3]),
274                username:     cell_text(&cells[4]),
275                get_turn:     parse_i32(&cells[5]),
276                rem_turn:     parse_i32(&cells[6]),
277                total_point:  parse_i32(&cells[7]),
278                action_point: parse_opt_i32(&cells[8]),
279                item_point:   parse_opt_i32(&cells[9]),
280                put_point:    parse_opt_i32(&cells[10]),
281                put_damage:   parse_opt_i32(&cells[11]),
282            };
283            // FIX: room number is inherited from the previous *battle* (not just
284            // the previous row) when it is absent, so grouping within the same
285            // table section works correctly.
286            let room = {
287                let r = parse_u32(&cells[0]);
288                if r > 0 { r } else { results.last().map(|b| b.room).unwrap_or(0) }
289            };
290            results.push(BattleResult {
291                room,
292                start_time: start_time_text,
293                end_time:   cell_text(&cells[2]),
294                players:    vec![player],
295            });
296        } else if let Some(last) = results.last_mut() {
297            last.players.push(PlayerResult {
298                order:        parse_u32(&cells[3]),
299                username:     cell_text(&cells[4]),
300                get_turn:     parse_i32(&cells[5]),
301                rem_turn:     parse_i32(&cells[6]),
302                total_point:  parse_i32(&cells[7]),
303                action_point: parse_opt_i32(&cells[8]),
304                item_point:   parse_opt_i32(&cells[9]),
305                put_point:    parse_opt_i32(&cells[10]),
306                put_damage:   parse_opt_i32(&cells[11]),
307            });
308        }
309    }
310
311    results
312}
313
314// ----------------------------------------------------------------
315// URLs
316// ----------------------------------------------------------------
317
318const SERVER_BASE_URL:  &str = "http://www7019ug.sakura.ne.jp/CHaserOnline003/Server/";
319const SERVER_CHECK_URL: &str = "http://www7019ug.sakura.ne.jp/CHaserOnline003/Server/UserCheck";
320const LIST_RESULT_URL:  &str = "http://www7019ug.sakura.ne.jp/CHaserOnline003/Server/listvsresult";
321
322// ----------------------------------------------------------------
323// Public API
324// ----------------------------------------------------------------
325
326/// Fetches and parses the battle result list.
327///
328/// # Arguments
329/// * `user`      - Login username
330/// * `pass`      - Login password
331/// * `query`     - Search parameters (`VsResultQuery::today()` for today's results)
332/// * `proxy_uri` - Optional proxy URI; `None` = auto-detect, `Some("")` = direct
333pub async fn fetch_vs_result(
334    user:      &str,
335    pass:      &str,
336    query:     VsResultQuery,
337    proxy_uri: Option<&str>,
338) -> Result<Vec<BattleResult>, BoxError> {
339    let proxy_mode = ProxyMode::from_option(proxy_uri);
340
341    // Step 1: Obtain JSESSIONID from Server base page.
342    let (_, jsession) =
343        send_follow_redirects(SERVER_BASE_URL, &[], &proxy_mode).await?;
344    let jsessionid = jsession.ok_or("JSESSIONID not found")?;
345    let cookie = format!("JSESSIONID={}", jsessionid);
346
347    // Step 2: Authenticate.
348    // FIX: user and pass are percent-encoded to prevent query parameter injection.
349    let check_url = format!(
350        "{}?user={}&pass={}&select=vsresultview",
351        SERVER_CHECK_URL,
352        url_encode(user),
353        url_encode(pass),
354    );
355    let _ = send_once(&check_url, &[("Cookie", cookie.clone())], &proxy_mode).await?;
356
357    // Step 3: Fetch result list.
358    let list_url = format!("{}?{}", LIST_RESULT_URL, query.to_query_string());
359    let (_, _, body) = send_once(&list_url, &[("Cookie", cookie)], &proxy_mode).await?;
360
361    // Step 4: Decode Shift-JIS and parse.
362    let (html, _, _) = SHIFT_JIS.decode(&body);
363    Ok(parse_results(&html))
364}