add calc answer

This commit is contained in:
mat 2023-12-20 17:17:46 -06:00
parent 2b2771c132
commit c656e65614
12 changed files with 167 additions and 31 deletions

8
Cargo.lock generated
View File

@ -299,6 +299,12 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "fend-core"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e470ea3be6ce980f4d7f6cc08a6084e7715f2b052eeb1f123f2d4d8fb1d35de1"
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -792,8 +798,10 @@ dependencies = [
"base64", "base64",
"bytes", "bytes",
"eyre", "eyre",
"fend-core",
"futures", "futures",
"html-escape", "html-escape",
"rand",
"regex", "regex",
"reqwest", "reqwest",
"scraper", "scraper",

View File

@ -12,8 +12,10 @@ axum = { version = "0.7.2", features = ["http2"] }
base64 = "0.21.5" base64 = "0.21.5"
bytes = "1.5.0" bytes = "1.5.0"
eyre = "0.6.11" eyre = "0.6.11"
fend-core = "1.3.3"
futures = "0.3.29" futures = "0.3.29"
html-escape = "0.2.13" html-escape = "0.2.13"
rand = "0.8.5"
regex = "1.10.2" regex = "1.10.2"
reqwest = { version = "0.11.23", default-features = false, features = [ reqwest = { version = "0.11.23", default-features = false, features = [
"rustls-tls", "rustls-tls",

View File

@ -1,3 +1,4 @@
pub mod calc;
pub mod ip; pub mod ip;
pub mod useragent; pub mod useragent;

View File

@ -0,0 +1,74 @@
use crate::engines::{EngineResponse, SearchQuery};
pub fn request(_client: &reqwest::Client, query: &SearchQuery) -> EngineResponse {
let query = query.query.as_str();
let Some(result_html) = evaluate(query, true) else {
return EngineResponse::new();
};
EngineResponse::answer_html(format!(
r#"<p class="answer-calc-query">{query} =</p>
<h3><b>{result_html}</b></h3>"#,
query = html_escape::encode_text(query),
))
}
pub fn request_autocomplete(_client: &reqwest::Client, query: &str) -> Vec<String> {
let mut results = Vec::new();
if let Some(result) = evaluate(query, false) {
results.push(format!("{query} = {result}"));
}
return results;
}
fn evaluate(query: &str, html: bool) -> Option<String> {
// at least 3 characters and not one of the short constants
if query.len() < 3 && !matches!(query.to_lowercase().as_str(), "pi" | "e" | "c") {
return None;
}
let mut context = fend_core::Context::new();
// make lowercase f and c work
context.define_custom_unit_v1("f", "f", "°F", &fend_core::CustomUnitAttribute::Alias);
context.define_custom_unit_v1("c", "c", "°C", &fend_core::CustomUnitAttribute::Alias);
// make random work
context.set_random_u32_fn(|| rand::random::<u32>());
if html {
// this makes it generate slightly nicer outputs for some queries like 2d6
context.set_output_mode_terminal();
}
let Ok(result) = fend_core::evaluate(query, &mut context) else {
return None;
};
let main_result = result.get_main_result();
if main_result == query {
return None;
}
if !html {
return Some(main_result.to_string());
}
let mut result_html = String::new();
for span in result.get_main_result_spans() {
let class = match span.kind() {
fend_core::SpanKind::Number | fend_core::SpanKind::Boolean => "answer-calc-constant",
_ => "",
};
if !class.is_empty() {
result_html.push_str(&format!(
r#"<span class="{class}">{text}</span>"#,
text = html_escape::encode_text(span.string())
));
} else {
result_html.push_str(&html_escape::encode_text(span.string()));
}
}
return Some(result_html);
}

View File

@ -9,5 +9,5 @@ pub fn request(_client: &reqwest::Client, query: &SearchQuery) -> EngineResponse
let ip = &query.ip; let ip = &query.ip;
EngineResponse::answer_html(format!("Your IP address is <b>{ip}</b>")) EngineResponse::answer_html(format!(r#"<h3><b>{ip}</b></h3>"#))
} }

View File

@ -3,7 +3,7 @@ use crate::engines::{EngineResponse, SearchQuery};
use super::regex; use super::regex;
pub fn request(_client: &reqwest::Client, query: &SearchQuery) -> EngineResponse { pub fn request(_client: &reqwest::Client, query: &SearchQuery) -> EngineResponse {
if !regex!("^what('s|s| is) my (user ?agent|ua)|ua|user ?agent$") if !regex!("^(what('s|s| is) my (user ?agent|ua)|ua|user ?agent)$")
.is_match(&query.query.to_lowercase()) .is_match(&query.query.to_lowercase())
{ {
return EngineResponse::new(); return EngineResponse::new();
@ -12,7 +12,7 @@ pub fn request(_client: &reqwest::Client, query: &SearchQuery) -> EngineResponse
let user_agent = query.request_headers.get("user-agent"); let user_agent = query.request_headers.get("user-agent");
EngineResponse::answer_html(if let Some(user_agent) = user_agent { EngineResponse::answer_html(if let Some(user_agent) = user_agent {
format!("Your user agent is <b>{user_agent}</b>") format!("<h3><b>{user_agent}</b></h3>")
} else { } else {
format!("You don't have a user agent") format!("You don't have a user agent")
}) })

View File

@ -8,8 +8,6 @@ use std::{
use futures::future::join_all; use futures::future::join_all;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use self::search::{bing, brave, google};
pub mod answer; pub mod answer;
pub mod search; pub mod search;
@ -22,6 +20,7 @@ pub enum Engine {
// answer // answer
Useragent, Useragent,
Ip, Ip,
Calc,
} }
impl Engine { impl Engine {
@ -32,6 +31,7 @@ impl Engine {
Engine::Brave, Engine::Brave,
Engine::Useragent, Engine::Useragent,
Engine::Ip, Engine::Ip,
Engine::Calc,
] ]
} }
@ -42,6 +42,7 @@ impl Engine {
Engine::Brave => "brave", Engine::Brave => "brave",
Engine::Useragent => "useragent", Engine::Useragent => "useragent",
Engine::Ip => "ip", Engine::Ip => "ip",
Engine::Calc => "calc",
} }
} }
@ -56,19 +57,20 @@ impl Engine {
pub fn request(&self, client: &reqwest::Client, query: &SearchQuery) -> RequestResponse { pub fn request(&self, client: &reqwest::Client, query: &SearchQuery) -> RequestResponse {
match self { match self {
Engine::Google => google::request(client, query).into(), Engine::Google => search::google::request(client, query).into(),
Engine::Bing => bing::request(client, query).into(), Engine::Bing => search::bing::request(client, query).into(),
Engine::Brave => search::brave::request(client, query).into(), Engine::Brave => search::brave::request(client, query).into(),
Engine::Useragent => answer::useragent::request(client, query).into(), Engine::Useragent => answer::useragent::request(client, query).into(),
Engine::Ip => answer::ip::request(client, query).into(), Engine::Ip => answer::ip::request(client, query).into(),
Engine::Calc => answer::calc::request(client, query).into(),
} }
} }
pub fn parse_response(&self, body: &str) -> eyre::Result<EngineResponse> { pub fn parse_response(&self, body: &str) -> eyre::Result<EngineResponse> {
match self { match self {
Engine::Google => google::parse_response(body), Engine::Google => search::google::parse_response(body),
Engine::Bing => bing::parse_response(body), Engine::Bing => search::bing::parse_response(body),
Engine::Brave => brave::parse_response(body), Engine::Brave => search::brave::parse_response(body),
_ => eyre::bail!("engine {self:?} can't parse response"), _ => eyre::bail!("engine {self:?} can't parse response"),
} }
} }
@ -77,17 +79,18 @@ impl Engine {
&self, &self,
client: &reqwest::Client, client: &reqwest::Client,
query: &str, query: &str,
) -> Option<reqwest::RequestBuilder> { ) -> Option<RequestAutocompleteResponse> {
match self { match self {
Engine::Google => Some(google::request_autocomplete(client, query)), Engine::Google => Some(search::google::request_autocomplete(client, query).into()),
Engine::Calc => Some(answer::calc::request_autocomplete(client, query).into()),
_ => None, _ => None,
} }
} }
pub fn parse_autocomplete_response(&self, body: &str) -> eyre::Result<Vec<String>> { pub fn parse_autocomplete_response(&self, body: &str) -> eyre::Result<Vec<String>> {
match self { match self {
Engine::Google => google::parse_autocomplete_response(body), Engine::Google => search::google::parse_autocomplete_response(body),
_ => Ok(Vec::new()), _ => eyre::bail!("engine {self:?} can't parse autocomplete response"),
} }
} }
} }
@ -110,7 +113,6 @@ pub enum RequestResponse {
Http(reqwest::RequestBuilder), Http(reqwest::RequestBuilder),
Instant(EngineResponse), Instant(EngineResponse),
} }
impl From<reqwest::RequestBuilder> for RequestResponse { impl From<reqwest::RequestBuilder> for RequestResponse {
fn from(req: reqwest::RequestBuilder) -> Self { fn from(req: reqwest::RequestBuilder) -> Self {
Self::Http(req) Self::Http(req)
@ -122,6 +124,21 @@ impl From<EngineResponse> for RequestResponse {
} }
} }
pub enum RequestAutocompleteResponse {
Http(reqwest::RequestBuilder),
Instant(Vec<String>),
}
impl From<reqwest::RequestBuilder> for RequestAutocompleteResponse {
fn from(req: reqwest::RequestBuilder) -> Self {
Self::Http(req)
}
}
impl From<Vec<String>> for RequestAutocompleteResponse {
fn from(res: Vec<String>) -> Self {
Self::Instant(res)
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct EngineSearchResult { pub struct EngineSearchResult {
pub url: String, pub url: String,
@ -258,9 +275,14 @@ pub async fn autocomplete_with_client_and_engines(
for engine in engines { for engine in engines {
if let Some(request) = engine.request_autocomplete(client, query) { if let Some(request) = engine.request_autocomplete(client, query) {
requests.push(async { requests.push(async {
let response = match request {
RequestAutocompleteResponse::Http(request) => {
let res = request.send().await?; let res = request.send().await?;
let body = res.text().await?; let body = res.text().await?;
let response = engine.parse_autocomplete_response(&body)?; engine.parse_autocomplete_response(&body)?
}
RequestAutocompleteResponse::Instant(response) => response,
};
Ok((*engine, response)) Ok((*engine, response))
}); });
} }

View File

@ -27,7 +27,9 @@ pub fn parse_response(body: &str) -> eyre::Result<EngineResponse> {
parse_html_response_with_opts( parse_html_response_with_opts(
body, body,
ParseOpts::new() ParseOpts::new()
.result("div.g, div.xpd") // xpd is weird, some results have it but it's usually used for ads?
// the :first-child filters out the ads though since for ads the first child is always a span
.result("div.g > div, div.xpd > div:first-child")
.title("h3") .title("h3")
.href("a[href]") .href("a[href]")
.description("div[data-sncf], div[style='-webkit-line-clamp:2']") .description("div[data-sncf], div[style='-webkit-line-clamp:2']")

View File

@ -6,7 +6,10 @@ pub fn normalize_url(url: &str) -> eyre::Result<String> {
return Ok(String::new()); return Ok(String::new());
} }
let mut url = Url::parse(url)?; let Ok(mut url) = Url::parse(url) else {
eprintln!("failed to parse url: {url}");
return Ok(url.to_string());
};
// make sure the scheme is https // make sure the scheme is https
if url.scheme() == "http" { if url.scheme() == "http" {
@ -22,11 +25,12 @@ pub fn normalize_url(url: &str) -> eyre::Result<String> {
url.set_path(path); url.set_path(path);
} }
// remove ref_src tracking param // remove tracking params
let query_pairs = url.query_pairs().into_owned(); let query_pairs = url.query_pairs().into_owned();
let mut new_query_pairs = Vec::new(); let mut new_query_pairs = Vec::new();
const TRACKING_PARAMS: &[&str] = &["ref_src", "_sm_au_"];
for (key, value) in query_pairs { for (key, value) in query_pairs {
if key != "ref_src" { if !TRACKING_PARAMS.contains(&key.as_str()) {
new_query_pairs.push((key, value)); new_query_pairs.push((key, value));
} }
} }

View File

@ -144,6 +144,12 @@ pub(super) fn parse_html_response_with_opts(
let url = normalize_url(&url)?; let url = normalize_url(&url)?;
let description = description_query_method.call(&result)?; let description = description_query_method.call(&result)?;
// this can happen on google if you search "roll d6"
let is_empty = description.is_empty() && title.is_empty();
if is_empty {
continue;
}
search_results.push(EngineSearchResult { search_results.push(EngineSearchResult {
url, url,
title, title,
@ -162,7 +168,7 @@ pub(super) fn parse_html_response_with_opts(
let description = featured_snippet_description_query_method.call(&featured_snippet)?; let description = featured_snippet_description_query_method.call(&featured_snippet)?;
// this can happen on google if you search "what's my user agent" // this can happen on google if you search "what's my user agent"
let is_empty = description.is_empty() && title.is_empty() && url.is_empty(); let is_empty = description.is_empty() && title.is_empty();
if is_empty { if is_empty {
None None
} else { } else {

View File

@ -9,7 +9,9 @@ searchInputEl.insertAdjacentElement("afterend", datalistEl);
searchInputEl.addEventListener("input", async (e) => { searchInputEl.addEventListener("input", async (e) => {
const value = e.target.value; const value = e.target.value;
const res = await fetch(`/autocomplete?q=${value}`).then((res) => res.json()); const res = await fetch(`/autocomplete?q=${encodeURIComponent(value)}`).then(
(res) => res.json()
);
const options = res[1]; const options = res[1];
datalistEl.innerHTML = ""; datalistEl.innerHTML = "";

View File

@ -97,13 +97,6 @@ h1 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* answer */
.answer {
margin-bottom: 1rem;
border: 1px solid #234;
padding: 0.5rem;
}
/* progress update */ /* progress update */
.progress-updates { .progress-updates {
margin-bottom: 1rem; margin-bottom: 1rem;
@ -122,3 +115,25 @@ h1 {
color: #7fd962; color: #7fd962;
font-weight: bold; font-weight: bold;
} }
/* answer */
.answer {
margin-bottom: 1rem;
border: 1px solid #234;
padding: 0.5rem;
}
.answer h3 {
margin: 0;
font-weight: normal;
font-size: 1.2rem;
}
/* styles for specific answers */
.answer-calc-query {
margin: 0;
opacity: 0.5;
}
.answer-calc-constant {
color: #d2a6ff;
white-space: pre;
}