perfect-postcode/server-rs/src/routes/rightmove.rs
2026-05-12 22:13:07 +01:00

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(&params.postcode) {
return Err((
StatusCode::BAD_REQUEST,
"'postcode' must be a full UK postcode".to_string(),
));
}
let postcode = normalize_postcode(&params.postcode);
let mut target = parse_rightmove_target(&params.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"));
}
}