use scraper::{Html, Selector};
#[derive(Debug, Clone, PartialEq)]
pub struct LoginForm {
pub action: String,
pub hidden_fields: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Verdict {
pub run_id: String,
pub problem_id: String,
pub problem_name: String,
pub verdict: String,
pub language: String,
pub runtime: String,
pub date: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("Login form not found in HTML")]
LoginFormNotFound,
#[error("Verdict table not found in HTML")]
VerdictTableNotFound,
#[error("No submissions found")]
NoSubmissions,
}
pub fn parse_login_form(html: &str) -> Result<LoginForm, ParseError> {
let document = Html::parse_document(html);
let form_selector =
Selector::parse("form#mod_loginform").expect("valid CSS selector for form#mod_loginform");
let form = document
.select(&form_selector)
.next()
.ok_or(ParseError::LoginFormNotFound)?;
let action = form.value().attr("action").unwrap_or("").to_string();
let input_selector = Selector::parse("input").expect("valid CSS selector for input");
let mut hidden_fields = Vec::new();
for input in form.select(&input_selector) {
let name = match input.value().attr("name") {
Some(n) => n.to_string(),
None => continue,
};
let value = input.value().attr("value").unwrap_or("").to_string();
hidden_fields.push((name, value));
}
Ok(LoginForm {
action,
hidden_fields,
})
}
pub fn parse_verdict_table(html: &str) -> Result<Vec<Verdict>, ParseError> {
let document = Html::parse_document(html);
let table_selector = Selector::parse("table").expect("valid CSS selector for table");
let tr_selector = Selector::parse("tr").expect("valid CSS selector for tr");
let td_selector = Selector::parse("td, th").expect("valid CSS selector for td/th");
let target_table = document
.select(&table_selector)
.find(|table| {
table
.select(&tr_selector)
.next()
.map(|first_row| {
let header_text = first_row.text().collect::<String>();
header_text.contains('#') && header_text.contains("Verdict")
})
.unwrap_or(false)
})
.ok_or(ParseError::VerdictTableNotFound)?;
let rows: Vec<_> = target_table.select(&tr_selector).collect();
if rows.len() < 2 {
return Err(ParseError::NoSubmissions);
}
let mut verdicts = Vec::new();
for row in &rows[1..] {
let cells: Vec<_> = row.select(&td_selector).collect();
if cells.len() < 7 {
continue; }
let run_id = cells[0].text().collect::<String>().trim().to_string();
let problem_id = cells[1].text().collect::<String>().trim().to_string();
let problem_name = cells[2].text().collect::<String>().trim().to_string();
let verdict = cells[3].text().collect::<String>().trim().to_string();
let language = cells[4].text().collect::<String>().trim().to_string();
let runtime = cells[5].text().collect::<String>().trim().to_string();
let date = cells[6].text().collect::<String>().trim().to_string();
verdicts.push(Verdict {
run_id,
problem_id,
problem_name,
verdict,
language,
runtime,
date,
});
}
if verdicts.is_empty() {
return Err(ParseError::NoSubmissions);
}
Ok(verdicts)
}
pub fn parse_latest_verdict(html: &str) -> Result<Verdict, ParseError> {
parse_verdict_table(html)?
.into_iter()
.next()
.ok_or(ParseError::NoSubmissions)
}
pub fn is_logged_in(html: &str) -> bool {
!html.contains("You need to login")
}
#[cfg(test)]
mod tests {
use super::*;
fn mock_login_form_html() -> String {
r#"
<html>
<body>
<div id="module_login">
<form id="mod_loginform" action="/index.php?option=com_comprofiler&task=login" method="post">
<input type="text" name="username" value="" class="inputbox" size="15" />
<input type="password" name="passwd" class="inputbox" size="15" />
<input type="checkbox" name="remember" value="yes" />
<input type="hidden" name="op2" value="login" />
<input type="hidden" name="lang" value="english" />
<input type="hidden" name="force_session" value="1" />
<input type="hidden" name="return" value="L2luZGV4LnBocD8%3D" />
<input type="hidden" name="message" value="0" />
<input type="hidden" name="loginfrom" value="loginmodule" />
<input type="hidden" name="cbsecuritym3" value="cbm_a1b2c3d4e5f6_token" />
<input type="hidden" name="j2cf4c0ff90f87bc0e1fb21187a441cc1" value="1" />
<input type="submit" name="Submit" value="Login" />
</form>
</div>
</body>
</html>
"#
.to_string()
}
fn mock_verdict_table_html() -> String {
r#"
<html>
<body>
<table>
<tr>
<th>#</th>
<th></th>
<th>Problem</th>
<th>Verdict</th>
<th>Language</th>
<th>Run Time</th>
<th>Submission Date</th>
</tr>
<tr>
<td>29964933</td>
<td><a href="/index.php?option=com_onlinejudge&page=problem&id=100">100</a></td>
<td><a href="/index.php?option=com_onlinejudge&page=problem&id=100">The 3n + 1 Problem</a></td>
<td>Accepted</td>
<td>C++11</td>
<td>0.140</td>
<td>2024-11-15 07:56:25</td>
</tr>
<tr>
<td>29964900</td>
<td><a href="/index.php?option=com_onlinejudge&page=problem&id=12315">12315</a></td>
<td><a href="/index.php?option=com_onlinejudge&page=problem&id=12315">The Starflyer Agents</a></td>
<td>Wrong Answer</td>
<td>Java</td>
<td>0.000</td>
<td>2024-11-14 12:30:00</td>
</tr>
<tr>
<td>29964800</td>
<td><a href="/index.php?option=com_onlinejudge&page=problem&id=50">50</a></td>
<td><a href="/index.php?option=com_onlinejudge&page=problem&id=50">Primary Arithmetic</a></td>
<td>In judge queue</td>
<td>PASCAL</td>
<td>-</td>
<td>2024-11-13 09:00:00</td>
</tr>
</table>
</body>
</html>
"#
.to_string()
}
fn mock_verdict_table_empty_html() -> String {
r#"
<html>
<body>
<table>
<tr>
<th>#</th>
<th></th>
<th>Problem</th>
<th>Verdict</th>
<th>Language</th>
<th>Run Time</th>
<th>Submission Date</th>
</tr>
</table>
</body>
</html>
"#
.to_string()
}
fn mock_not_logged_in_html() -> String {
r#"
<html>
<body>
<div>You need to login</div>
</body>
</html>
"#
.to_string()
}
fn mock_logged_in_html() -> String {
r#"
<html>
<body>
<div>Welcome, user123!</div>
</body>
</html>
"#
.to_string()
}
#[test]
fn test_parse_login_form_extracts_action() {
let html = mock_login_form_html();
let form = parse_login_form(&html).unwrap();
assert_eq!(form.action, "/index.php?option=com_comprofiler&task=login");
}
#[test]
fn test_parse_login_form_extracts_all_inputs() {
let html = mock_login_form_html();
let form = parse_login_form(&html).unwrap();
assert!(form.hidden_fields.len() >= 10);
}
#[test]
fn test_parse_login_form_extracts_hidden_fields() {
let html = mock_login_form_html();
let form = parse_login_form(&html).unwrap();
let field_map: std::collections::HashMap<&str, &str> = form
.hidden_fields
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(field_map.get("op2").copied(), Some("login"));
assert_eq!(field_map.get("lang").copied(), Some("english"));
assert_eq!(field_map.get("force_session").copied(), Some("1"));
assert_eq!(field_map.get("return").copied(), Some("L2luZGV4LnBocD8%3D"));
assert_eq!(field_map.get("message").copied(), Some("0"));
assert_eq!(field_map.get("loginfrom").copied(), Some("loginmodule"));
assert_eq!(
field_map.get("cbsecuritym3").copied(),
Some("cbm_a1b2c3d4e5f6_token")
);
}
#[test]
fn test_parse_login_form_extracts_csrf_token() {
let html = mock_login_form_html();
let form = parse_login_form(&html).unwrap();
let csrf = form
.hidden_fields
.iter()
.find(|(name, _)| name.starts_with('j'))
.expect("random-name CSRF field should be present");
assert_eq!(csrf.1, "1");
}
#[test]
fn test_parse_login_form_not_found() {
let html = r#"<html><body><p>No form here</p></body></html>"#;
let result = parse_login_form(html);
assert!(matches!(result, Err(ParseError::LoginFormNotFound)));
}
#[test]
fn test_parse_login_form_includes_visible_inputs() {
let html = mock_login_form_html();
let form = parse_login_form(&html).unwrap();
let names: Vec<&str> = form.hidden_fields.iter().map(|(k, _)| k.as_str()).collect();
assert!(names.contains(&"username"));
assert!(names.contains(&"passwd"));
assert!(names.contains(&"remember"));
assert!(names.contains(&"Submit"));
}
#[test]
fn test_parse_verdict_table_extracts_all_rows() {
let html = mock_verdict_table_html();
let verdicts = parse_verdict_table(&html).unwrap();
assert_eq!(verdicts.len(), 3);
}
#[test]
fn test_parse_verdict_table_first_row() {
let html = mock_verdict_table_html();
let verdicts = parse_verdict_table(&html).unwrap();
let v = &verdicts[0];
assert_eq!(v.run_id, "29964933");
assert_eq!(v.problem_id, "100");
assert_eq!(v.problem_name, "The 3n + 1 Problem");
assert_eq!(v.verdict, "Accepted");
assert_eq!(v.language, "C++11");
assert_eq!(v.runtime, "0.140");
assert_eq!(v.date, "2024-11-15 07:56:25");
}
#[test]
fn test_parse_verdict_table_second_row() {
let html = mock_verdict_table_html();
let verdicts = parse_verdict_table(&html).unwrap();
let v = &verdicts[1];
assert_eq!(v.run_id, "29964900");
assert_eq!(v.problem_id, "12315");
assert_eq!(v.problem_name, "The Starflyer Agents");
assert_eq!(v.verdict, "Wrong Answer");
assert_eq!(v.language, "Java");
assert_eq!(v.runtime, "0.000");
assert_eq!(v.date, "2024-11-14 12:30:00");
}
#[test]
fn test_parse_verdict_table_in_judge_queue() {
let html = mock_verdict_table_html();
let verdicts = parse_verdict_table(&html).unwrap();
let v = &verdicts[2];
assert_eq!(v.verdict, "In judge queue");
assert_eq!(v.runtime, "-");
}
#[test]
fn test_parse_verdict_table_not_found() {
let html = r#"<html><body><p>No table here</p></body></html>"#;
let result = parse_verdict_table(html);
assert!(matches!(result, Err(ParseError::VerdictTableNotFound)));
}
#[test]
fn test_parse_verdict_table_no_submissions() {
let html = mock_verdict_table_empty_html();
let result = parse_verdict_table(&html);
assert!(matches!(result, Err(ParseError::NoSubmissions)));
}
#[test]
fn test_parse_verdict_table_ignores_wrong_table() {
let html = r#"
<html>
<body>
<table>
<tr><td>Navigation</td></tr>
<tr><td>Home</td></tr>
</table>
</body>
</html>
"#;
let result = parse_verdict_table(html);
assert!(matches!(result, Err(ParseError::VerdictTableNotFound)));
}
#[test]
fn test_parse_verdict_table_problem_id_from_link() {
let html = mock_verdict_table_html();
let verdicts = parse_verdict_table(&html).unwrap();
assert_eq!(verdicts[0].problem_id, "100");
assert_eq!(verdicts[1].problem_id, "12315");
}
#[test]
fn test_parse_latest_verdict_returns_first() {
let html = mock_verdict_table_html();
let v = parse_latest_verdict(&html).unwrap();
assert_eq!(v.run_id, "29964933");
assert_eq!(v.verdict, "Accepted");
}
#[test]
fn test_parse_latest_verdict_empty_table() {
let html = mock_verdict_table_empty_html();
let result = parse_latest_verdict(&html);
assert!(matches!(result, Err(ParseError::NoSubmissions)));
}
#[test]
fn test_parse_latest_verdict_no_table() {
let html = r#"<html><body></body></html>"#;
let result = parse_latest_verdict(html);
assert!(matches!(result, Err(ParseError::VerdictTableNotFound)));
}
#[test]
fn test_is_logged_in_false_when_not_logged_in() {
let html = mock_not_logged_in_html();
assert!(!is_logged_in(&html));
}
#[test]
fn test_is_logged_in_true_when_logged_in() {
let html = mock_logged_in_html();
assert!(is_logged_in(&html));
}
#[test]
fn test_is_logged_in_true_for_empty_html() {
assert!(is_logged_in(""));
}
#[test]
fn test_is_logged_in_case_sensitive() {
let html = r#"<div>you need to login</div>"#;
assert!(is_logged_in(html));
}
#[test]
fn test_parse_login_form_with_multiple_hidden_fields() {
let html = r#"
<html>
<body>
<form id="mod_loginform" action="/index.php?option=com_comprofiler&task=login" method="post">
<input type="hidden" name="op2" value="login" />
<input type="hidden" name="lang" value="english" />
<input type="hidden" name="force_session" value="1" />
<input type="hidden" name="return" value="L2luZGV4LnBocD8%3D" />
<input type="hidden" name="message" value="0" />
<input type="hidden" name="loginfrom" value="loginmodule" />
<input type="hidden" name="cbsecuritym3" value="cbm_xyz123token" />
<input type="hidden" name="j1a2b3c4d5e6f7g8" value="1" />
<input type="hidden" name="extra_joomla_field" value="something" />
<input type="text" name="username" value="" />
<input type="password" name="passwd" value="" />
<input type="submit" name="Submit" value="Login" />
</form>
</body>
</html>
"#;
let form = parse_login_form(html).unwrap();
assert_eq!(form.action, "/index.php?option=com_comprofiler&task=login");
assert!(form.hidden_fields.len() >= 10);
let field_map: std::collections::HashMap<&str, &str> = form
.hidden_fields
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(
field_map.get("cbsecuritym3").copied(),
Some("cbm_xyz123token")
);
assert_eq!(
field_map.get("extra_joomla_field").copied(),
Some("something")
);
}
#[test]
fn test_parse_verdict_table_with_extra_columns() {
let html = r#"
<html>
<body>
<table>
<tr>
<th>#</th>
<th></th>
<th>Problem</th>
<th>Verdict</th>
<th>Language</th>
<th>Run Time</th>
<th>Submission Date</th>
</tr>
<tr>
<td>12345</td>
<td><a href="/problem/100">100</a></td>
<td><a href="/problem/100">Test Problem</a></td>
<td>Compile Error</td>
<td>ANSI C</td>
<td>-</td>
<td>2025-01-01 00:00:00</td>
</tr>
</table>
</body>
</html>
"#;
let verdicts = parse_verdict_table(html).unwrap();
assert_eq!(verdicts.len(), 1);
assert_eq!(verdicts[0].verdict, "Compile Error");
assert_eq!(verdicts[0].language, "ANSI C");
}
}