Skip to main content

chaser_util/
realtime_map_view.rs

1//! CHaser Online game map view scraper.
2//!
3//! # Quick start
4//!
5//! ```no_run
6//! use chaser_util::realtime_map_view::{fetch_map_view, MapViewOptions};
7//!
8//! #[tokio::main]
9//! async fn main() {
10//!     let result = fetch_map_view("hot", "hot", MapViewOptions::default()).await.unwrap();
11//!     println!("room={} turn={} next={}", result.room_name, result.turn, result.next_player);
12//! }
13//! ```
14
15use encoding_rs::SHIFT_JIS;
16use regex::Regex;
17use std::sync::OnceLock;
18
19use crate::proxy::{send_follow_redirects, url_encode, BoxError, ProxyMode};
20
21// ----------------------------------------------------------------
22// Public data types
23// ----------------------------------------------------------------
24
25/// One map cell (numeric part of the image filename).
26/// e.g. "012.gif" → 12, "000.gif" → 0
27pub type TileId = u32;
28
29const SERVER_IMAGE_BASE: &str = "http://www7019ug.sakura.ne.jp/CHaserOnline003/img/";
30
31/// Returns the full image URL for a given TileId.
32pub fn tile_image_url(tile: TileId) -> String {
33    format!("{}{:03}.gif", SERVER_IMAGE_BASE, tile)
34}
35
36/// Player information (from the right-side table).
37#[derive(Debug, Clone)]
38pub struct PlayerInfo {
39    pub username: String,
40    pub attr_a:   i32,
41    pub attr_i:   i32,
42    pub attr_p:   i32,
43    pub attr_pd:  i32,
44    pub attr_t:   i32,
45    /// Command list (one entry per command line, e.g. "gr 12,0,12").
46    pub commands: Vec<String>,
47}
48
49/// Full result of a map view fetch.
50#[derive(Debug, Clone)]
51pub struct MapViewResult {
52    pub room_name:   String,
53    pub turn:        u32,
54    pub next_player: String,
55    /// Map cells as a 2D array \[row\]\[col\].
56    pub map:         Vec<Vec<TileId>>,
57    pub players:     Vec<PlayerInfo>,
58}
59
60/// Fetch options.
61#[derive(Debug, Clone, Default)]
62pub struct MapViewOptions {
63    /// Optional proxy URI.  `None` = auto-detect, `Some("")` = direct connection.
64    pub proxy_uri: Option<String>,
65}
66
67// ----------------------------------------------------------------
68// Lazy-compiled regexes
69// ----------------------------------------------------------------
70
71fn re_img_src() -> &'static Regex {
72    static RE: OnceLock<Regex> = OnceLock::new();
73    RE.get_or_init(|| Regex::new(r"/img/(\d{1,3})\.gif").expect("re_img_src"))
74}
75
76fn re_turn() -> &'static Regex {
77    static RE: OnceLock<Regex> = OnceLock::new();
78    RE.get_or_init(|| Regex::new(r"turn=(\d+)\s+Next=([^<\s]+)").expect("re_turn"))
79}
80
81fn re_room_name() -> &'static Regex {
82    static RE: OnceLock<Regex> = OnceLock::new();
83    RE.get_or_init(|| Regex::new(r"(?i)<h1[^>]*>[^\[]*\[([^\]]+)\]").expect("re_room_name"))
84}
85
86// ----------------------------------------------------------------
87// HTML parsing helpers
88// ----------------------------------------------------------------
89
90fn inner_text<'a>(node: &tl::Node<'a>, parser: &'a tl::Parser<'a>) -> String {
91    match node {
92        tl::Node::Raw(b)   => b.as_utf8_str().into_owned(),
93        tl::Node::Tag(tag) => tag
94            .children().top().iter()
95            .filter_map(|h| h.get(parser))
96            .map(|n| inner_text(n, parser))
97            .collect(),
98        _ => String::new(),
99    }
100}
101
102pub(crate) fn parse_room_name(html: &str) -> String {
103    re_room_name()
104        .captures(html)
105        .and_then(|c| c.get(1))
106        .map(|m| m.as_str().trim().to_string())
107        .unwrap_or_default()
108}
109
110pub(crate) fn parse_map(html: &str) -> (Vec<Vec<TileId>>, u32, String) {
111    let (turn, next_player) = re_turn()
112        .captures(html)
113        .map(|c| (
114            c.get(1).and_then(|m| m.as_str().parse().ok()).unwrap_or(0),
115            c.get(2).map(|m| m.as_str().to_string()).unwrap_or_default(),
116        ))
117        .unwrap_or((0, String::new()));
118
119    let lower = html.to_ascii_lowercase();
120    let table_start = match lower.find(r#"cellpadding="0""#)
121        .and_then(|i| html[..i].rfind('<'))
122    {
123        Some(i) => i,
124        None    => return (vec![], turn, next_player),
125    };
126    let table_end = match lower[table_start..].find("</table") {
127        Some(i) => table_start + i,
128        None    => return (vec![], turn, next_player),
129    };
130    let table_html  = &html[table_start..table_end];
131    let table_lower = &lower[table_start..table_end];
132
133    let tr_positions: Vec<usize> = table_lower
134        .match_indices("<tr")
135        .map(|(i, _)| i)
136        .collect();
137
138    let mut map: Vec<Vec<TileId>> = Vec::new();
139    for (idx, &tr_start) in tr_positions.iter().enumerate() {
140        let tr_end   = tr_positions.get(idx + 1).copied().unwrap_or(table_html.len());
141        let tr_slice = &table_html[tr_start..tr_end];
142
143        let row: Vec<TileId> = re_img_src()
144            .captures_iter(tr_slice)
145            .filter_map(|c| c.get(1).and_then(|m| {
146                let id: u32 = m.as_str().parse().ok()?;
147                if id >= 1000 { return None; }
148                Some(id)
149            }))
150            .collect();
151
152        if !row.is_empty() {
153            map.push(row);
154        }
155    }
156
157    (map, turn, next_player)
158}
159
160pub(crate) fn parse_players(dom: &tl::VDom) -> Vec<PlayerInfo> {
161    let parser = dom.parser();
162
163    let player_table = match dom
164        .query_selector(r#"table[border="1"]"#)
165        .and_then(|mut q| q.next())
166        .and_then(|h| h.get(parser))
167    {
168        Some(tl::Node::Tag(t)) => t,
169        _ => return vec![],
170    };
171
172    let table_html: String = player_table
173        .children().top().iter()
174        .filter_map(|h| h.get(parser))
175        .map(|n| n.outer_html(parser).to_string())
176        .collect();
177
178    let Ok(dom2) = tl::parse(&table_html, tl::ParserOptions::default()) else {
179        return vec![];
180    };
181    let p2 = dom2.parser();
182
183    let tr_htmls: Vec<String> = dom2
184        .query_selector("tr").into_iter().flatten()
185        .filter_map(|h| h.get(p2))
186        .filter_map(|n| match n {
187            tl::Node::Tag(t) => Some(
188                t.children().top().iter()
189                    .filter_map(|h| h.get(p2))
190                    .map(|n| n.outer_html(p2).to_string())
191                    .collect::<String>()
192            ),
193            _ => None,
194        })
195        .collect();
196
197    let mut player_data: Vec<(String, i32, i32, i32, i32, i32)> = Vec::new();
198    if let Some(tr0) = tr_htmls.first() {
199        if let Ok(d) = tl::parse(tr0, tl::ParserOptions::default()) {
200            let p = d.parser();
201            let td_htmls: Vec<String> = d.query_selector(r#"td[valign="top"]"#)
202                .into_iter().flatten()
203                .filter_map(|h| h.get(p))
204                .filter_map(|n| match n {
205                    tl::Node::Tag(t) => Some(
206                        t.children().top().iter()
207                            .filter_map(|h| h.get(p))
208                            .map(|n| n.outer_html(p).to_string())
209                            .collect::<String>()
210                    ),
211                    _ => None,
212                })
213                .collect();
214
215            for td_html in &td_htmls {
216                if let Ok(td_dom) = tl::parse(td_html, tl::ParserOptions::default()) {
217                    let tp = td_dom.parser();
218                    let text: String = td_dom.nodes().iter()
219                        .map(|n| inner_text(n, tp))
220                        .collect::<Vec<_>>()
221                        .join(" ");
222
223                    let mut username  = String::new();
224                    let mut attr_a    = 0i32;
225                    let mut attr_i    = 0i32;
226                    let mut attr_p    = 0i32;
227                    let mut attr_pd   = 0i32;
228                    let mut attr_t    = 0i32;
229                    let mut found_any = false;
230
231                    for token in text.split_whitespace() {
232                        if let Some(v) = token.strip_prefix("A:") {
233                            attr_a = v.parse().unwrap_or(0); found_any = true;
234                        } else if let Some(v) = token.strip_prefix("I:") {
235                            attr_i = v.parse().unwrap_or(0);
236                        } else if let Some(v) = token.strip_prefix("PD:") {
237                            attr_pd = v.parse().unwrap_or(0);
238                        } else if let Some(v) = token.strip_prefix("P:") {
239                            attr_p = v.trim_end_matches(']').parse().unwrap_or(0);
240                        } else if let Some(v) = token.strip_prefix("T:") {
241                            attr_t = v.trim_end_matches(']').parse().unwrap_or(0);
242                        } else if username.is_empty() && !token.starts_with('[') && !token.is_empty() {
243                            username = token.to_string();
244                        }
245                    }
246                    if found_any {
247                        player_data.push((username, attr_a, attr_i, attr_p, attr_pd, attr_t));
248                    }
249                }
250            }
251        }
252    }
253
254    let mut all_commands: Vec<Vec<String>> = Vec::new();
255    if let Some(tr1) = tr_htmls.get(1) {
256        if let Ok(d) = tl::parse(tr1, tl::ParserOptions::default()) {
257            let p = d.parser();
258            let font_htmls: Vec<String> = d.query_selector("font")
259                .into_iter().flatten()
260                .filter_map(|h| h.get(p))
261                .filter_map(|n| match n {
262                    tl::Node::Tag(t) => Some(
263                        t.children().top().iter()
264                            .filter_map(|h| h.get(p))
265                            .map(|n| n.outer_html(p).to_string())
266                            .collect::<String>()
267                    ),
268                    _ => None,
269                })
270                .collect();
271
272            for font_html in &font_htmls {
273                if let Ok(fd) = tl::parse(font_html, tl::ParserOptions::default()) {
274                    let fp = fd.parser();
275                    let text = fd.nodes().iter()
276                        .map(|n| inner_text(n, fp))
277                        .collect::<String>();
278                    let cmds: Vec<String> = text.lines()
279                        .map(|l| l.trim().to_string())
280                        .filter(|l| !l.is_empty())
281                        .collect();
282                    all_commands.push(cmds);
283                }
284            }
285        }
286    }
287
288    let mut players = Vec::new();
289    for (i, (username, attr_a, attr_i, attr_p, attr_pd, attr_t)) in player_data.into_iter().enumerate() {
290        let commands = all_commands.get(i).cloned().unwrap_or_default();
291        players.push(PlayerInfo { username, attr_a, attr_i, attr_p, attr_pd, attr_t, commands });
292    }
293    players
294}
295
296// ----------------------------------------------------------------
297// URLs
298// ----------------------------------------------------------------
299
300const SERVER_CHECK_URL: &str =
301    "http://www7019ug.sakura.ne.jp/CHaserOnline003/Server/UserCheck";
302const MAP_VIEW_URL: &str =
303    "http://www7019ug.sakura.ne.jp/CHaserOnline003/Server/MapView.jsp";
304
305// ----------------------------------------------------------------
306// Core logic
307// ----------------------------------------------------------------
308
309async fn fetch_inner(
310    user:       &str,
311    pass:       &str,
312    proxy_mode: ProxyMode,
313) -> Result<MapViewResult, BoxError> {
314    // FIX: user and pass are percent-encoded to prevent query parameter injection.
315    let check_url = format!(
316        "{}?user={}&pass={}&select=mapview",
317        SERVER_CHECK_URL,
318        url_encode(user),
319        url_encode(pass),
320    );
321    let (_, jsession) = send_follow_redirects(&check_url, &[], &proxy_mode).await?;
322    let jsessionid = jsession.ok_or("Server JSESSIONID not found")?;
323    let cookie = format!("JSESSIONID={}", jsessionid);
324
325    let (body, _) =
326        send_follow_redirects(MAP_VIEW_URL, &[("Cookie", cookie)], &proxy_mode).await?;
327    let (html, _, _) = SHIFT_JIS.decode(&body);
328
329    let room_name                = parse_room_name(&html);
330    let (map, turn, next_player) = parse_map(&html);
331    let dom                      = tl::parse(&html, tl::ParserOptions::default())?;
332    let players                  = parse_players(&dom);
333
334    Ok(MapViewResult { room_name, turn, next_player, map, players })
335}
336
337// ----------------------------------------------------------------
338// Public API
339// ----------------------------------------------------------------
340
341/// Fetches the real-time game map view.
342pub async fn fetch_map_view(
343    user: &str,
344    pass: &str,
345    opts: MapViewOptions,
346) -> Result<MapViewResult, BoxError> {
347    let proxy_mode = ProxyMode::from_option(opts.proxy_uri.as_deref());
348    fetch_inner(user, pass, proxy_mode).await
349}