Last active
March 30, 2020 08:18
-
-
Save qryxip/bd320e1e1fee1b2f74aa0cf290f70cae to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! This code is licensed under [CC0-1.0](https://creativecommons.org/publicdomain/zero/1.0). | |
| //! | |
| //! ```cargo | |
| //! [package] | |
| //! name = "atcoder-cat" | |
| //! version = "0.0.0" | |
| //! authors = ["Ryo Yamashita <[email protected]>"] | |
| //! edition = "2018" | |
| //! license = "CC0-1.0" | |
| //! publish = false | |
| //! | |
| //! [dependencies] | |
| //! anyhow = "1.0.27" | |
| //! base64 = "0.12.0" | |
| //! env_logger = "0.7.1" | |
| //! log = "0.4.8" | |
| //! maplit = "1.0.2" | |
| //! once_cell = "1.3.1" | |
| //! reqwest = { version = "0.10.4", default-features = false, features = ["rustls-tls", "blocking", "cookies", "gzip", "json"] } | |
| //! rpassword = "4.0.5" | |
| //! rprompt = "1.0.5" | |
| //! scraper = "0.11.0" | |
| //! serde = { version = "1.0.105", features = ["derive"] } | |
| //! serde_json = "1.0.48" | |
| //! shell-escape = "0.1.4" | |
| //! structopt = "0.3.12" | |
| //! url = "2.1.1" | |
| //! ``` | |
| use anyhow::{bail, Context as _}; | |
| use env_logger::fmt::{Color, WriteStyle}; | |
| use log::{info, Level, LevelFilter}; | |
| use maplit::hashmap; | |
| use once_cell::sync::Lazy; | |
| use reqwest::redirect::Policy; | |
| use scraper::selector::Selector; | |
| use scraper::Html; | |
| use serde::de::Error as _; | |
| use serde::{Deserialize, Deserializer, Serialize}; | |
| use structopt::StructOpt; | |
| use url::Url; | |
| use std::io::{self, Write as _}; | |
| use std::num::ParseIntError; | |
| use std::thread; | |
| use std::time::Duration; | |
| macro_rules! selector(($s:expr $(,)?) => ({ | |
| static SELECTOR: Lazy<Selector> = Lazy::new(|| Selector::parse($s).unwrap()); | |
| &SELECTOR | |
| })); | |
| fn main() -> anyhow::Result<()> { | |
| let Opt { | |
| timeout, | |
| contest, | |
| color, | |
| path, | |
| } = Opt::from_args(); | |
| init_logger(color); | |
| let username = rprompt::prompt_reply_stderr("Username: ")?; | |
| let password = rpassword::read_password_from_tty(Some("Password: "))?; | |
| let client = setup_client(timeout)?; | |
| let csrf_token = get(&client, "/login", &[200])? | |
| .html()? | |
| .extract_csrf_token()?; | |
| let payload = hashmap!( | |
| "csrf_token" => csrf_token, | |
| "username" => username, | |
| "password" => password, | |
| ); | |
| post_form(&client, "/login", &payload, &[302])?; | |
| if get(&client, "/settings", &[200, 302])?.status() == 302 { | |
| bail!("Failed to login"); | |
| } | |
| let csrf_token = get( | |
| &client, | |
| &format!("/contests/{}/custom_test", contest), | |
| &[200], | |
| )? | |
| .html()? | |
| .extract_csrf_token()?; | |
| let mut acc = vec![]; | |
| while { | |
| let code = BASH_CODE_TEMPLATE | |
| .replace("{{file}}", &shell_escape::escape((&path).into())) | |
| .replace("{{offset}}", &acc.len().to_string()) | |
| .replace("{{chunk-len}}", &CHUNK_LEN.to_string()); | |
| let payload = hashmap!( | |
| "data.LanguageId" => BASH_ID, | |
| "sourceCode" => &code, | |
| "input" => "", | |
| "csrf_token" => &csrf_token, | |
| ); | |
| post_form( | |
| &client, | |
| &format!("/contests/{}/custom_test/submit/json", contest), | |
| &payload, | |
| &[200], | |
| )?; | |
| loop { | |
| let ResponsePayload { result } = get( | |
| &client, | |
| &format!("/contests/{}/custom_test/json", contest), | |
| &[200], | |
| )? | |
| .json()?; | |
| info!("Result.Status = {}", result.status); | |
| if result.source_code == code.as_bytes() | |
| && result.input == b"" | |
| && result.status == 3 | |
| && result.language_id.to_string() == BASH_ID | |
| { | |
| if result.exit_code != 0 { | |
| bail!( | |
| "Failed with code {}: {:?}", | |
| result.exit_code, | |
| String::from_utf8_lossy(&result.error), | |
| ); | |
| } | |
| acc.extend_from_slice(&result.output); | |
| break result.output.len() == CHUNK_LEN; | |
| } | |
| info!("Waiting {:?}...", INTERVAL); | |
| thread::sleep(INTERVAL); | |
| } | |
| } {} | |
| let mut stdout = io::stdout(); | |
| stdout.write_all(&acc)?; | |
| stdout.flush()?; | |
| return Ok(()); | |
| const INTERVAL: Duration = Duration::from_secs(2); | |
| const CHUNK_LEN: usize = 2048; | |
| static BASH_ID: &str = "4007"; | |
| static BASH_CODE_TEMPLATE: &str = r#" | |
| FILE={{file}} | |
| OFFSET={{offset}} | |
| content="$(cat "$FILE" && printf '#')" && content="${content%#}" && echo -n "${content:$OFFSET:{{chunk-len}}}" | |
| "#; | |
| #[derive(Deserialize)] | |
| #[serde(rename_all = "PascalCase")] | |
| struct ResponsePayload { | |
| result: ResponsePayloadResult, | |
| } | |
| #[derive(Deserialize)] | |
| #[serde(rename_all = "PascalCase")] | |
| struct ResponsePayloadResult { | |
| #[serde(deserialize_with = "deser_b64")] | |
| source_code: Vec<u8>, | |
| #[serde(deserialize_with = "deser_b64")] | |
| input: Vec<u8>, | |
| #[serde(deserialize_with = "deser_b64")] | |
| output: Vec<u8>, | |
| #[serde(deserialize_with = "deser_b64")] | |
| error: Vec<u8>, | |
| exit_code: i32, | |
| status: u32, | |
| language_id: u32, | |
| } | |
| fn deser_b64<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error> | |
| where | |
| D: Deserializer<'de>, | |
| { | |
| let b64 = String::deserialize(deserializer)?; | |
| base64::decode(&b64).map_err(D::Error::custom) | |
| } | |
| } | |
| #[derive(StructOpt, Debug)] | |
| struct Opt { | |
| /// Timeout | |
| #[structopt(long, value_name("SECS"), parse(try_from_str = parse_seconds))] | |
| timeout: Option<Duration>, | |
| #[structopt(long, value_name("STRING"), default_value("language-test-202001"))] | |
| contest: String, | |
| /// Coloring | |
| #[structopt( | |
| long, | |
| value_name("WHEN"), | |
| default_value("auto"), | |
| possible_values(&["auto", "always", "never"]), | |
| parse(from_str = parse_write_style_unwrap) | |
| )] | |
| color: WriteStyle, | |
| /// Path to the file to retrieve | |
| path: String, | |
| } | |
| fn parse_seconds(s: &str) -> std::result::Result<Duration, ParseIntError> { | |
| s.parse().map(Duration::from_millis) | |
| } | |
| /// Parses `s` to a `WriteStyle`. | |
| /// | |
| /// # Panics | |
| /// | |
| /// Panics `s` is not "auto", "always", or "never". | |
| fn parse_write_style_unwrap(s: &str) -> WriteStyle { | |
| match s { | |
| "auto" => WriteStyle::Auto, | |
| "always" => WriteStyle::Always, | |
| "never" => WriteStyle::Never, | |
| _ => panic!(r#"expected "auto", "always", or "never""#), | |
| } | |
| } | |
| fn init_logger(color: WriteStyle) { | |
| env_logger::Builder::new() | |
| .format(|buf, record| { | |
| macro_rules! style(($fg:expr, $intense:expr) => ({ | |
| let mut style = buf.style(); | |
| style.set_color($fg).set_intense($intense); | |
| style | |
| })); | |
| let color = match record.level() { | |
| Level::Error => Color::Red, | |
| Level::Warn => Color::Yellow, | |
| Level::Info => Color::Cyan, | |
| Level::Debug => Color::Green, | |
| Level::Trace => Color::White, | |
| }; | |
| let path = record | |
| .module_path() | |
| .map(|p| p.split("::").next().unwrap()) | |
| .filter(|&p| p != module_path!().split("::").next().unwrap()) | |
| .map(|p| format!(" {}", p)) | |
| .unwrap_or_default(); | |
| writeln!( | |
| buf, | |
| "{}{}{}{} {}", | |
| style!(Color::Black, true).value('['), | |
| style!(color, false).value(record.level()), | |
| path, | |
| style!(Color::Black, true).value(']'), | |
| record.args(), | |
| ) | |
| }) | |
| .filter_level(LevelFilter::Info) | |
| .write_style(color) | |
| .init(); | |
| } | |
| fn setup_client(timeout: Option<Duration>) -> reqwest::Result<reqwest::blocking::Client> { | |
| return reqwest::blocking::ClientBuilder::new() | |
| .user_agent(USER_AGENT) | |
| .cookie_store(true) | |
| .redirect(Policy::none()) | |
| .referer(false) | |
| .timeout(timeout) | |
| .build(); | |
| static USER_AGENT: &str = "atcoder-cat <[email protected]>"; | |
| } | |
| fn get( | |
| client: &reqwest::blocking::Client, | |
| path: &str, | |
| statuses: &[u16], | |
| ) -> anyhow::Result<reqwest::blocking::Response> { | |
| let url = url(path)?; | |
| info!("GET {}", url); | |
| let res = client.get(url.clone()).send()?; | |
| info!("{}", res.status()); | |
| if !statuses.contains(&res.status().as_u16()) { | |
| bail!("{}: expected {:?}, got {}", url, statuses, res.status()); | |
| } | |
| Ok(res) | |
| } | |
| fn post_form( | |
| client: &reqwest::blocking::Client, | |
| path: &str, | |
| form: &impl Serialize, | |
| statuses: &[u16], | |
| ) -> anyhow::Result<reqwest::blocking::Response> { | |
| let url = url(path)?; | |
| info!("POST {}", url); | |
| let res = client.post(url.clone()).form(form).send()?; | |
| info!("{}", res.status()); | |
| if !statuses.contains(&res.status().as_u16()) { | |
| bail!("{}: expected {:?}, got {}", url, statuses, res.status()); | |
| } | |
| Ok(res) | |
| } | |
| fn url(path: &str) -> std::result::Result<Url, url::ParseError> { | |
| return BASE.join(path); | |
| static BASE: Lazy<Url> = Lazy::new(|| "https://atcoder.jp".parse().unwrap()); | |
| } | |
| trait ResponseExt { | |
| fn html(self) -> reqwest::Result<Html>; | |
| } | |
| impl ResponseExt for reqwest::blocking::Response { | |
| fn html(self) -> reqwest::Result<Html> { | |
| let text = self.text()?; | |
| Ok(Html::parse_document(&text)) | |
| } | |
| } | |
| trait HtmlExt { | |
| fn extract_csrf_token(&self) -> anyhow::Result<String>; | |
| } | |
| impl HtmlExt for Html { | |
| fn extract_csrf_token(&self) -> anyhow::Result<String> { | |
| (|| { | |
| let token = self | |
| .select(selector!("[name=\"csrf_token\"]")) | |
| .next()? | |
| .value() | |
| .attr("value")? | |
| .to_owned(); | |
| Some(token).filter(|token| !token.is_empty()) | |
| })() | |
| .with_context(|| "failed to find the CSRF token") | |
| .with_context(|| "failed to scrape") | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment