237 lines
7 KiB
Rust
237 lines
7 KiB
Rust
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"));
|
|
}
|
|
}
|