lgtm
This commit is contained in:
parent
11711c57e6
commit
81a16f543c
21 changed files with 29072 additions and 1913 deletions
237
server-rs/src/routes/rightmove.rs
Normal file
237
server-rs/src/routes/rightmove.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Redirect;
|
||||
use reqwest::Url;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::state::SharedState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
||||
const RIGHTMOVE_TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
|
||||
const RIGHTMOVE_HOST: &str = "www.rightmove.co.uk";
|
||||
const RIGHTMOVE_FIND_PATH: &str = "/property-for-sale/find.html";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RightmoveRedirectParams {
|
||||
postcode: String,
|
||||
target: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RightmoveTypeaheadResponse {
|
||||
#[serde(default)]
|
||||
matches: Vec<RightmoveTypeaheadMatch>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RightmoveTypeaheadMatch {
|
||||
id: Value,
|
||||
#[serde(rename = "type")]
|
||||
match_type: String,
|
||||
#[serde(default, rename = "displayName")]
|
||||
display_name: String,
|
||||
}
|
||||
|
||||
pub async fn get_rightmove_redirect(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Query(params): Query<RightmoveRedirectParams>,
|
||||
) -> Result<Redirect, (StatusCode, String)> {
|
||||
if !looks_like_full_uk_postcode(¶ms.postcode) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"'postcode' must be a full UK postcode".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let postcode = normalize_postcode(¶ms.postcode);
|
||||
let mut target = parse_rightmove_target(¶ms.target)?;
|
||||
let state = shared.load_state();
|
||||
|
||||
match fetch_exact_postcode_location_identifier(&state.http_client, &postcode).await {
|
||||
Some(location_identifier) => {
|
||||
apply_exact_postcode_location(&mut target, &postcode, &location_identifier);
|
||||
}
|
||||
None => warn!(
|
||||
postcode,
|
||||
"Could not resolve exact Rightmove postcode location"
|
||||
),
|
||||
}
|
||||
|
||||
Ok(Redirect::temporary(target.as_str()))
|
||||
}
|
||||
|
||||
async fn fetch_exact_postcode_location_identifier(
|
||||
client: &reqwest::Client,
|
||||
postcode: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!(
|
||||
"{}?query={}&limit=5",
|
||||
RIGHTMOVE_TYPEAHEAD_URL,
|
||||
urlencoding::encode(postcode)
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| warn!(postcode, "Rightmove typeahead request failed: {err}"))
|
||||
.ok()?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
warn!(
|
||||
postcode,
|
||||
status = %response.status(),
|
||||
"Rightmove typeahead returned an error"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let typeahead: RightmoveTypeaheadResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
postcode,
|
||||
"Failed to parse Rightmove typeahead response: {err}"
|
||||
)
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
typeahead.matches.iter().find_map(|item| {
|
||||
if !item.match_type.eq_ignore_ascii_case("POSTCODE") {
|
||||
return None;
|
||||
}
|
||||
if compact_postcode(&item.display_name) != compact_postcode(postcode) {
|
||||
return None;
|
||||
}
|
||||
rightmove_id_to_string(&item.id).map(|id| format!("POSTCODE^{id}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_rightmove_target(target: &str) -> Result<Url, (StatusCode, String)> {
|
||||
let url = Url::parse(target).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"'target' must be a valid Rightmove URL".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if url.scheme() != "https"
|
||||
|| url.host_str() != Some(RIGHTMOVE_HOST)
|
||||
|| url.path() != RIGHTMOVE_FIND_PATH
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"'target' must be a Rightmove property search URL".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn apply_exact_postcode_location(url: &mut Url, postcode: &str, location_identifier: &str) {
|
||||
let mut pairs: Vec<(String, String)> = url
|
||||
.query_pairs()
|
||||
.filter(|(key, _)| {
|
||||
key != "searchLocation"
|
||||
&& key != "useLocationIdentifier"
|
||||
&& key != "locationIdentifier"
|
||||
&& key != "radius"
|
||||
})
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
|
||||
pairs.push(("searchLocation".to_string(), postcode.to_string()));
|
||||
pairs.push(("useLocationIdentifier".to_string(), "true".to_string()));
|
||||
pairs.push((
|
||||
"locationIdentifier".to_string(),
|
||||
location_identifier.to_string(),
|
||||
));
|
||||
pairs.push(("radius".to_string(), "0.0".to_string()));
|
||||
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.clear();
|
||||
for (key, value) in pairs {
|
||||
query.append_pair(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
fn rightmove_id_to_string(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(id) if !id.trim().is_empty() => Some(id.clone()),
|
||||
Value::Number(id) => Some(id.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_postcode(postcode: &str) -> String {
|
||||
postcode
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace())
|
||||
.map(|ch| ch.to_ascii_uppercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn looks_like_full_uk_postcode(postcode: &str) -> bool {
|
||||
let compact = compact_postcode(postcode);
|
||||
let bytes = compact.as_bytes();
|
||||
if !(5..=7).contains(&bytes.len()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let outward_len = bytes.len() - 3;
|
||||
bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[..outward_len]
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_alphanumeric())
|
||||
&& bytes[outward_len].is_ascii_digit()
|
||||
&& bytes[outward_len + 1].is_ascii_alphabetic()
|
||||
&& bytes[outward_len + 2].is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rewrites_rightmove_url_to_exact_postcode_location() {
|
||||
let mut url = Url::parse(
|
||||
"https://www.rightmove.co.uk/property-for-sale/find.html?searchLocation=SW1A+1AA&useLocationIdentifier=true&locationIdentifier=OUTCODE%5E2506&radius=0.25&minPrice=100000",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
apply_exact_postcode_location(&mut url, "SW1A 1AA", "POSTCODE^837246");
|
||||
|
||||
let pairs: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
|
||||
assert_eq!(pairs.get("searchLocation").unwrap(), "SW1A 1AA");
|
||||
assert_eq!(pairs.get("useLocationIdentifier").unwrap(), "true");
|
||||
assert_eq!(pairs.get("locationIdentifier").unwrap(), "POSTCODE^837246");
|
||||
assert_eq!(pairs.get("radius").unwrap(), "0.0");
|
||||
assert_eq!(pairs.get("minPrice").unwrap(), "100000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_rightmove_redirect_targets() {
|
||||
assert!(parse_rightmove_target("https://example.com/property-for-sale/find.html").is_err());
|
||||
assert!(
|
||||
parse_rightmove_target("http://www.rightmove.co.uk/property-for-sale/find.html")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
parse_rightmove_target("https://www.rightmove.co.uk/property-to-rent/find.html")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_full_postcode_shape() {
|
||||
assert!(looks_like_full_uk_postcode("SW1A 1AA"));
|
||||
assert!(looks_like_full_uk_postcode("e16an"));
|
||||
assert!(!looks_like_full_uk_postcode("SW1A"));
|
||||
assert!(!looks_like_full_uk_postcode("not a postcode"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue