diff --git a/Dockerfile b/Dockerfile
index 4f6c9a1..481d385 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Stage 1: Build frontend
-FROM node:20-slim AS frontend
+FROM node:22-slim AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
@@ -7,7 +7,7 @@ COPY frontend/ ./
RUN npm run build:no-prerender
# Stage 2: Build Rust server
-FROM rust:1.83-bookworm AS server
+FROM rust:1.84-bookworm AS server
WORKDIR /app
COPY server-rs/ server-rs/
WORKDIR /app/server-rs
diff --git a/analyses/bank_postcode_boundaries.ipynb b/analyses/bank_postcode_boundaries.ipynb
index b4197f3..abd5a95 100644
--- a/analyses/bank_postcode_boundaries.ipynb
+++ b/analyses/bank_postcode_boundaries.ipynb
@@ -813,8 +813,14 @@
],
"source": [
"# Build area lookup from both sets\n",
- "areas_before = {f[\"properties\"][\"postcode\"]: f[\"properties\"][\"area_sqm\"] for f in no_green[\"features\"]}\n",
- "areas_after = {f[\"properties\"][\"postcode\"]: f[\"properties\"][\"area_sqm\"] for f in with_green[\"features\"]}\n",
+ "areas_before = {\n",
+ " f[\"properties\"][\"postcode\"]: f[\"properties\"][\"area_sqm\"]\n",
+ " for f in no_green[\"features\"]\n",
+ "}\n",
+ "areas_after = {\n",
+ " f[\"properties\"][\"postcode\"]: f[\"properties\"][\"area_sqm\"]\n",
+ " for f in with_green[\"features\"]\n",
+ "}\n",
"\n",
"# Compute percentage removed\n",
"diffs = []\n",
@@ -1161,16 +1167,23 @@
"\n",
"colormap = cm.LinearColormap(\n",
" colors=[\"#ffffcc\", \"#fd8d3c\", \"#e31a1c\", \"#800026\"],\n",
- " vmin=0, vmax=min(max_pct, 90),\n",
+ " vmin=0,\n",
+ " vmax=min(max_pct, 90),\n",
" caption=\"% area removed by greenspace\",\n",
")\n",
"\n",
+ "\n",
"# Show original boundaries, colored by how much was removed\n",
"def style_by_removal(feature):\n",
" pc = feature[\"properties\"][\"postcode\"]\n",
" pct = diff_lookup.get(pc, 0)\n",
" if pct <= 1:\n",
- " return {\"fillColor\": \"#cccccc\", \"color\": \"#999\", \"weight\": 0.5, \"fillOpacity\": 0.15}\n",
+ " return {\n",
+ " \"fillColor\": \"#cccccc\",\n",
+ " \"color\": \"#999\",\n",
+ " \"weight\": 0.5,\n",
+ " \"fillOpacity\": 0.15,\n",
+ " }\n",
" return {\n",
" \"fillColor\": colormap(min(pct, 90)),\n",
" \"color\": \"white\",\n",
@@ -1178,6 +1191,7 @@
" \"fillOpacity\": 0.6,\n",
" }\n",
"\n",
+ "\n",
"folium.GeoJson(\n",
" no_green,\n",
" name=\"Greenspace removal %\",\n",
diff --git a/analyses/price_model_evaluation.ipynb b/analyses/price_model_evaluation.ipynb
index d8cf9cc..2bce6b3 100644
--- a/analyses/price_model_evaluation.ipynb
+++ b/analyses/price_model_evaluation.ipynb
@@ -54,25 +54,32 @@
" ape = np.abs(p - a) / a\n",
" err = p - a\n",
" return {\n",
- " \"MdAPE (%)\": f\"{np.median(ape)*100:.1f}\",\n",
- " \"% within 10%\": f\"{np.mean(ape <= 0.10)*100:.1f}\",\n",
- " \"% within 20%\": f\"{np.mean(ape <= 0.20)*100:.1f}\",\n",
- " \"% within 30%\": f\"{np.mean(ape <= 0.30)*100:.1f}\",\n",
+ " \"MdAPE (%)\": f\"{np.median(ape) * 100:.1f}\",\n",
+ " \"% within 10%\": f\"{np.mean(ape <= 0.10) * 100:.1f}\",\n",
+ " \"% within 20%\": f\"{np.mean(ape <= 0.20) * 100:.1f}\",\n",
+ " \"% within 30%\": f\"{np.mean(ape <= 0.30) * 100:.1f}\",\n",
" \"MAE (\\u00a3)\": f\"{np.mean(np.abs(err)):,.0f}\",\n",
" \"Mean signed error (\\u00a3)\": f\"{np.mean(err):+,.0f}\",\n",
" \"n\": f\"{len(a):,}\",\n",
" }\n",
"\n",
+ "\n",
"actual = backtest_df[\"actual_price\"].to_numpy().astype(np.float64)\n",
"metrics = {\n",
- " \"Naive\": compute_metrics(actual, backtest_df[\"input_price\"].to_numpy().astype(np.float64)),\n",
- " \"Index\": compute_metrics(actual, backtest_df[\"predicted\"].to_numpy().astype(np.float64)),\n",
+ " \"Naive\": compute_metrics(\n",
+ " actual, backtest_df[\"input_price\"].to_numpy().astype(np.float64)\n",
+ " ),\n",
+ " \"Index\": compute_metrics(\n",
+ " actual, backtest_df[\"predicted\"].to_numpy().astype(np.float64)\n",
+ " ),\n",
"}\n",
"\n",
- "metrics_table = pl.DataFrame([\n",
- " {\"Metric\": k, **{stage: v[k] for stage, v in metrics.items()}}\n",
- " for k in list(metrics[\"Naive\"].keys())\n",
- "])\n",
+ "metrics_table = pl.DataFrame(\n",
+ " [\n",
+ " {\"Metric\": k, **{stage: v[k] for stage, v in metrics.items()}}\n",
+ " for k in list(metrics[\"Naive\"].keys())\n",
+ " ]\n",
+ ")\n",
"metrics_table"
]
},
@@ -91,8 +98,7 @@
"source": [
"# National index (average across all sectors weighted by n_pairs)\n",
"national = (\n",
- " index_df\n",
- " .group_by(\"year\")\n",
+ " index_df.group_by(\"year\")\n",
" .agg(\n",
" (pl.col(\"log_index\") * pl.col(\"n_pairs\")).sum() / pl.col(\"n_pairs\").sum(),\n",
" )\n",
@@ -107,14 +113,23 @@
"\n",
"# If not enough, pick some with high/low n_pairs\n",
"if len(sample_sectors) < 3:\n",
- " sector_counts = index_df.group_by(\"sector\").agg(pl.col(\"n_pairs\").first()).sort(\"n_pairs\", descending=True)\n",
+ " sector_counts = (\n",
+ " index_df.group_by(\"sector\")\n",
+ " .agg(pl.col(\"n_pairs\").first())\n",
+ " .sort(\"n_pairs\", descending=True)\n",
+ " )\n",
" top = sector_counts.head(2)[\"sector\"].to_list()\n",
" bottom = sector_counts.filter(pl.col(\"n_pairs\") > 0).tail(2)[\"sector\"].to_list()\n",
" sample_sectors = list(set(sample_sectors + top + bottom))[:5]\n",
"\n",
"samples = index_df.filter(pl.col(\"sector\").is_in(sample_sectors))\n",
"\n",
- "combined = pl.concat([national.select(\"sector\", \"year\", \"log_index\"), samples.select(\"sector\", \"year\", \"log_index\")])\n",
+ "combined = pl.concat(\n",
+ " [\n",
+ " national.select(\"sector\", \"year\", \"log_index\"),\n",
+ " samples.select(\"sector\", \"year\", \"log_index\"),\n",
+ " ]\n",
+ ")\n",
"\n",
"# Normalize: index = 100 at base year (earliest available)\n",
"combined = combined.with_columns(\n",
@@ -122,7 +137,10 @@
")\n",
"\n",
"fig = px.line(\n",
- " combined.to_pandas(), x=\"year\", y=\"index_100\", color=\"sector\",\n",
+ " combined.to_pandas(),\n",
+ " x=\"year\",\n",
+ " y=\"index_100\",\n",
+ " color=\"sector\",\n",
" title=\"Repeat-Sales Price Index (base year = 100)\",\n",
" labels={\"index_100\": \"Index (base=100)\", \"year\": \"Year\"},\n",
")\n",
@@ -155,8 +173,10 @@
"\n",
"fig.update_layout(\n",
" title=\"Absolute Percentage Error Distribution\",\n",
- " xaxis_title=\"APE (%)\", yaxis_title=\"Count\",\n",
- " barmode=\"overlay\", height=500,\n",
+ " xaxis_title=\"APE (%)\",\n",
+ " yaxis_title=\"Count\",\n",
+ " barmode=\"overlay\",\n",
+ " height=500,\n",
")\n",
"fig.show()"
]
@@ -183,17 +203,27 @@
"pred = sample[\"predicted\"].to_numpy().astype(np.float64)\n",
"\n",
"fig = go.Figure()\n",
- "fig.add_trace(go.Scattergl(\n",
- " x=actual_sample, y=pred, mode=\"markers\",\n",
- " marker=dict(size=2, opacity=0.3), name=\"Index\",\n",
- "))\n",
+ "fig.add_trace(\n",
+ " go.Scattergl(\n",
+ " x=actual_sample,\n",
+ " y=pred,\n",
+ " mode=\"markers\",\n",
+ " marker=dict(size=2, opacity=0.3),\n",
+ " name=\"Index\",\n",
+ " )\n",
+ ")\n",
"# 45-degree reference line\n",
"min_val = max(10_000, min(actual_sample.min(), np.nanmin(pred)))\n",
"max_val = min(5_000_000, max(actual_sample.max(), np.nanmax(pred)))\n",
- "fig.add_trace(go.Scatter(\n",
- " x=[min_val, max_val], y=[min_val, max_val],\n",
- " mode=\"lines\", line=dict(color=\"red\", dash=\"dash\"), showlegend=False,\n",
- "))\n",
+ "fig.add_trace(\n",
+ " go.Scatter(\n",
+ " x=[min_val, max_val],\n",
+ " y=[min_val, max_val],\n",
+ " mode=\"lines\",\n",
+ " line=dict(color=\"red\", dash=\"dash\"),\n",
+ " showlegend=False,\n",
+ " )\n",
+ ")\n",
"fig.update_xaxes(type=\"log\", title_text=\"Actual (\\u00a3)\")\n",
"fig.update_yaxes(type=\"log\", title_text=\"Predicted (\\u00a3)\")\n",
"fig.update_layout(title=\"Predicted vs Actual Price (log scale, 10K sample)\", height=500)\n",
@@ -234,12 +264,22 @@
" for name, arr in [(\"Naive\", naive), (\"Index\", pred)]:\n",
" ape = np.abs(arr[mask] - actual[mask]) / actual[mask]\n",
" valid = np.isfinite(ape)\n",
- " rows.append({\"Price Band\": label, \"Method\": name, \"MdAPE (%)\": float(np.median(ape[valid]) * 100)})\n",
+ " rows.append(\n",
+ " {\n",
+ " \"Price Band\": label,\n",
+ " \"Method\": name,\n",
+ " \"MdAPE (%)\": float(np.median(ape[valid]) * 100),\n",
+ " }\n",
+ " )\n",
"\n",
"band_df = pl.DataFrame(rows)\n",
"fig = px.bar(\n",
- " band_df.to_pandas(), x=\"Price Band\", y=\"MdAPE (%)\", color=\"Method\",\n",
- " barmode=\"group\", title=\"MdAPE by Price Band\",\n",
+ " band_df.to_pandas(),\n",
+ " x=\"Price Band\",\n",
+ " y=\"MdAPE (%)\",\n",
+ " color=\"Method\",\n",
+ " barmode=\"group\",\n",
+ " title=\"MdAPE by Price Band\",\n",
" category_orders={\"Price Band\": [b[2] for b in bands]},\n",
")\n",
"fig.update_layout(height=450)\n",
@@ -264,7 +304,9 @@
")\n",
"\n",
"# Top 20 areas by volume\n",
- "top_areas = bt.group_by(\"area\").len().sort(\"len\", descending=True).head(20)[\"area\"].to_list()\n",
+ "top_areas = (\n",
+ " bt.group_by(\"area\").len().sort(\"len\", descending=True).head(20)[\"area\"].to_list()\n",
+ ")\n",
"\n",
"actual_np = bt[\"actual_price\"].to_numpy().astype(np.float64)\n",
"pred_np = bt[\"predicted\"].to_numpy().astype(np.float64)\n",
@@ -279,12 +321,18 @@
" p = arr[mask]\n",
" valid = np.isfinite(p) & (a > 0)\n",
" ape = np.abs(p[valid] - a[valid]) / a[valid]\n",
- " rows.append({\"Area\": area, \"Method\": name, \"MdAPE (%)\": float(np.median(ape) * 100)})\n",
+ " rows.append(\n",
+ " {\"Area\": area, \"Method\": name, \"MdAPE (%)\": float(np.median(ape) * 100)}\n",
+ " )\n",
"\n",
"area_df = pl.DataFrame(rows)\n",
"fig = px.bar(\n",
- " area_df.to_pandas(), x=\"Area\", y=\"MdAPE (%)\", color=\"Method\",\n",
- " barmode=\"group\", title=\"MdAPE by Postcode Area (Top 20 by Volume)\",\n",
+ " area_df.to_pandas(),\n",
+ " x=\"Area\",\n",
+ " y=\"MdAPE (%)\",\n",
+ " color=\"Method\",\n",
+ " barmode=\"group\",\n",
+ " title=\"MdAPE by Postcode Area (Top 20 by Volume)\",\n",
" category_orders={\"Area\": top_areas},\n",
")\n",
"fig.update_layout(height=500)\n",
@@ -324,11 +372,20 @@
" p = arr[mask]\n",
" valid = np.isfinite(p) & (a > 0)\n",
" ape = np.abs(p[valid] - a[valid]) / a[valid]\n",
- " rows.append({\"Gap (years)\": gap, \"Method\": name, \"MdAPE (%)\": float(np.median(ape) * 100)})\n",
+ " rows.append(\n",
+ " {\n",
+ " \"Gap (years)\": gap,\n",
+ " \"Method\": name,\n",
+ " \"MdAPE (%)\": float(np.median(ape) * 100),\n",
+ " }\n",
+ " )\n",
"\n",
"gap_df = pl.DataFrame(rows)\n",
"fig = px.line(\n",
- " gap_df.to_pandas(), x=\"Gap (years)\", y=\"MdAPE (%)\", color=\"Method\",\n",
+ " gap_df.to_pandas(),\n",
+ " x=\"Gap (years)\",\n",
+ " y=\"MdAPE (%)\",\n",
+ " color=\"Method\",\n",
" title=\"MdAPE by Holding Period (years between input and actual sale)\",\n",
" markers=True,\n",
")\n",
diff --git a/analyses/rightmove_buy.ipynb b/analyses/rightmove_buy.ipynb
index cf806cf..97a0839 100644
--- a/analyses/rightmove_buy.ipynb
+++ b/analyses/rightmove_buy.ipynb
@@ -52,7 +52,9 @@
"pl.Config.set_tbl_rows(20)\n",
"pl.Config.set_fmt_str_lengths(80)\n",
"\n",
- "df = pl.read_parquet(\"/volumes/syncthing/Projects/property-map/property-data/rightmove_buy.parquet\")\n",
+ "df = pl.read_parquet(\n",
+ " \"/volumes/syncthing/Projects/property-map/property-data/rightmove_buy.parquet\"\n",
+ ")\n",
"schema = df.schema\n",
"print(f\"Total rows: {len(df):,}\")\n",
"print(f\"Columns ({len(schema)}):\")\n",
@@ -150,11 +152,13 @@
],
"source": [
"# Null counts\n",
- "null_df = pl.DataFrame({\n",
- " \"column\": df.columns,\n",
- " \"nulls\": [df[c].null_count() for c in df.columns],\n",
- " \"pct\": [f\"{df[c].null_count()/len(df)*100:.1f}%\" for c in df.columns],\n",
- "})\n",
+ "null_df = pl.DataFrame(\n",
+ " {\n",
+ " \"column\": df.columns,\n",
+ " \"nulls\": [df[c].null_count() for c in df.columns],\n",
+ " \"pct\": [f\"{df[c].null_count() / len(df) * 100:.1f}%\" for c in df.columns],\n",
+ " }\n",
+ ")\n",
"null_df.filter(pl.col(\"nulls\") > 0)"
]
},
@@ -197,13 +201,17 @@
" \"price = 0\": len(df.filter(pl.col(\"price\") == 0)),\n",
" \"price > 50M\": len(df.filter(pl.col(\"price\") > 50_000_000)),\n",
" \"floorspace > 10,000 sqm\": len(df.filter(pl.col(\"floorspace_sqm\") > 10_000)),\n",
- " \"latitude outside UK (< 49 or > 61)\": len(df.filter((pl.col(\"latitude\") < 49) | (pl.col(\"latitude\") > 61))),\n",
- " \"longitude outside UK (< -8 or > 2)\": len(df.filter((pl.col(\"longitude\") < -8) | (pl.col(\"longitude\") > 2))),\n",
+ " \"latitude outside UK (< 49 or > 61)\": len(\n",
+ " df.filter((pl.col(\"latitude\") < 49) | (pl.col(\"latitude\") > 61))\n",
+ " ),\n",
+ " \"longitude outside UK (< -8 or > 2)\": len(\n",
+ " df.filter((pl.col(\"longitude\") < -8) | (pl.col(\"longitude\") > 2))\n",
+ " ),\n",
" \"house_share = true\": len(df.filter(pl.col(\"house_share\"))),\n",
"}\n",
"print(\"Data quality issues:\")\n",
"for desc, count in issues.items():\n",
- " print(f\" {desc}: {count:,} ({count/len(df)*100:.2f}%)\")"
+ " print(f\" {desc}: {count:,} ({count / len(df) * 100:.2f}%)\")"
]
},
{
@@ -230,7 +238,7 @@
" & (pl.col(\"longitude\") >= -8)\n",
" & (pl.col(\"longitude\") <= 2)\n",
")\n",
- "print(f\"Clean rows: {len(clean):,} ({len(clean)/len(df)*100:.1f}% of original)\")"
+ "print(f\"Clean rows: {len(clean):,} ({len(clean) / len(df) * 100:.1f}% of original)\")"
]
},
{
@@ -1126,8 +1134,12 @@
"# Price histogram (clipped to 2nd-98th percentile)\n",
"lo, hi = price.quantile(0.02), price.quantile(0.98)\n",
"clipped = clean.filter((pl.col(\"price\") >= lo) & (pl.col(\"price\") <= hi))\n",
- "fig = px.histogram(clipped.to_pandas(), x=\"price\", nbins=80,\n",
- " title=f\"Asking Price Distribution (£{lo:,.0f} - £{hi:,.0f}, 2nd-98th pctl)\")\n",
+ "fig = px.histogram(\n",
+ " clipped.to_pandas(),\n",
+ " x=\"price\",\n",
+ " nbins=80,\n",
+ " title=f\"Asking Price Distribution (£{lo:,.0f} - £{hi:,.0f}, 2nd-98th pctl)\",\n",
+ ")\n",
"fig.update_layout(height=400, xaxis_title=\"Asking Price (£)\", yaxis_title=\"Count\")\n",
"fig.show()"
]
@@ -439978,9 +439990,13 @@
],
"source": [
"# Price by property type\n",
- "fig = px.box(clean.filter(pl.col(\"price\") <= 2_000_000).to_pandas(),\n",
- " x=\"property_type\", y=\"price\", color=\"property_type\",\n",
- " title=\"Price by Property Type (capped at £2M for readability)\")\n",
+ "fig = px.box(\n",
+ " clean.filter(pl.col(\"price\") <= 2_000_000).to_pandas(),\n",
+ " x=\"property_type\",\n",
+ " y=\"price\",\n",
+ " color=\"property_type\",\n",
+ " title=\"Price by Property Type (capped at £2M for readability)\",\n",
+ ")\n",
"fig.update_layout(height=500, showlegend=False, yaxis_title=\"Price (£)\")\n",
"fig.show()"
]
@@ -440079,9 +440095,7 @@
"source": [
"# Price qualifier breakdown\n",
"pq = clean[\"price_qualifier\"].value_counts().sort(\"count\", descending=True)\n",
- "pq = pq.with_columns(\n",
- " (pl.col(\"count\") / pl.col(\"count\").sum() * 100).alias(\"pct\")\n",
- ")\n",
+ "pq = pq.with_columns((pl.col(\"count\") / pl.col(\"count\").sum() * 100).alias(\"pct\"))\n",
"pq"
]
},
@@ -440928,8 +440942,12 @@
"source": [
"# Property type distribution\n",
"type_counts = clean[\"property_type\"].value_counts().sort(\"count\", descending=True)\n",
- "fig = px.pie(type_counts.to_pandas(), names=\"property_type\", values=\"count\",\n",
- " title=\"Property Type Distribution\")\n",
+ "fig = px.pie(\n",
+ " type_counts.to_pandas(),\n",
+ " names=\"property_type\",\n",
+ " values=\"count\",\n",
+ " title=\"Property Type Distribution\",\n",
+ ")\n",
"fig.update_layout(height=400)\n",
"fig.show()"
]
@@ -441805,9 +441823,16 @@
],
"source": [
"# Top 20 sub-types\n",
- "sub_counts = clean[\"property_sub_type\"].value_counts().sort(\"count\", descending=True).head(20)\n",
- "fig = px.bar(sub_counts.to_pandas(), x=\"count\", y=\"property_sub_type\", orientation=\"h\",\n",
- " title=\"Top 20 Property Sub-types\")\n",
+ "sub_counts = (\n",
+ " clean[\"property_sub_type\"].value_counts().sort(\"count\", descending=True).head(20)\n",
+ ")\n",
+ "fig = px.bar(\n",
+ " sub_counts.to_pandas(),\n",
+ " x=\"count\",\n",
+ " y=\"property_sub_type\",\n",
+ " orientation=\"h\",\n",
+ " title=\"Top 20 Property Sub-types\",\n",
+ ")\n",
"fig.update_layout(height=600, yaxis={\"categoryorder\": \"total ascending\"})\n",
"fig.show()"
]
@@ -442643,9 +442668,15 @@
],
"source": [
"# Tenure split\n",
- "tenure_counts = clean[\"tenure\"].drop_nulls().value_counts().sort(\"count\", descending=True)\n",
- "fig = px.pie(tenure_counts.to_pandas(), names=\"tenure\", values=\"count\",\n",
- " title=f\"Tenure Split ({clean['tenure'].null_count():,} unknown / {clean['tenure'].null_count()/len(clean)*100:.1f}% missing)\")\n",
+ "tenure_counts = (\n",
+ " clean[\"tenure\"].drop_nulls().value_counts().sort(\"count\", descending=True)\n",
+ ")\n",
+ "fig = px.pie(\n",
+ " tenure_counts.to_pandas(),\n",
+ " names=\"tenure\",\n",
+ " values=\"count\",\n",
+ " title=f\"Tenure Split ({clean['tenure'].null_count():,} unknown / {clean['tenure'].null_count() / len(clean) * 100:.1f}% missing)\",\n",
+ ")\n",
"fig.update_layout(height=400)\n",
"fig.show()"
]
@@ -443546,8 +443577,14 @@
" .agg(pl.len().alias(\"count\"))\n",
" .sort(\"property_type\")\n",
")\n",
- "fig = px.bar(tenure_by_type.to_pandas(), x=\"property_type\", y=\"count\", color=\"tenure\",\n",
- " barmode=\"group\", title=\"Tenure by Property Type\")\n",
+ "fig = px.bar(\n",
+ " tenure_by_type.to_pandas(),\n",
+ " x=\"property_type\",\n",
+ " y=\"count\",\n",
+ " color=\"tenure\",\n",
+ " barmode=\"group\",\n",
+ " title=\"Tenure by Property Type\",\n",
+ ")\n",
"fig.update_layout(height=400)\n",
"fig.show()"
]
@@ -444412,9 +444449,12 @@
],
"source": [
"# Bedroom distribution\n",
- "bed_counts = clean.filter(pl.col(\"bedrooms\") <= 10)[\"bedrooms\"].value_counts().sort(\"bedrooms\")\n",
- "fig = px.bar(bed_counts.to_pandas(), x=\"bedrooms\", y=\"count\",\n",
- " title=\"Bedroom Count Distribution\")\n",
+ "bed_counts = (\n",
+ " clean.filter(pl.col(\"bedrooms\") <= 10)[\"bedrooms\"].value_counts().sort(\"bedrooms\")\n",
+ ")\n",
+ "fig = px.bar(\n",
+ " bed_counts.to_pandas(), x=\"bedrooms\", y=\"count\", title=\"Bedroom Count Distribution\"\n",
+ ")\n",
"fig.update_layout(height=400)\n",
"fig.show()"
]
@@ -445279,16 +445319,25 @@
")\n",
"\n",
"fig = go.Figure()\n",
- "fig.add_trace(go.Bar(\n",
- " x=price_by_beds[\"bedrooms\"], y=price_by_beds[\"median_price\"],\n",
- " name=\"Median\", error_y=dict(type=\"data\",\n",
- " symmetric=False,\n",
- " array=(price_by_beds[\"p75\"] - price_by_beds[\"median_price\"]).to_list(),\n",
- " arrayminus=(price_by_beds[\"median_price\"] - price_by_beds[\"p25\"]).to_list()\n",
+ "fig.add_trace(\n",
+ " go.Bar(\n",
+ " x=price_by_beds[\"bedrooms\"],\n",
+ " y=price_by_beds[\"median_price\"],\n",
+ " name=\"Median\",\n",
+ " error_y=dict(\n",
+ " type=\"data\",\n",
+ " symmetric=False,\n",
+ " array=(price_by_beds[\"p75\"] - price_by_beds[\"median_price\"]).to_list(),\n",
+ " arrayminus=(price_by_beds[\"median_price\"] - price_by_beds[\"p25\"]).to_list(),\n",
+ " ),\n",
" )\n",
- "))\n",
- "fig.update_layout(title=\"Median Price by Bedrooms (with IQR)\", height=400,\n",
- " xaxis_title=\"Bedrooms\", yaxis_title=\"Price (£)\")\n",
+ ")\n",
+ "fig.update_layout(\n",
+ " title=\"Median Price by Bedrooms (with IQR)\",\n",
+ " height=400,\n",
+ " xaxis_title=\"Bedrooms\",\n",
+ " yaxis_title=\"Price (£)\",\n",
+ ")\n",
"fig.show()"
]
},
@@ -446263,8 +446312,14 @@
" .agg(pl.len().alias(\"count\"))\n",
" .sort(\"property_type\", \"bedrooms\")\n",
")\n",
- "fig = px.bar(beds_by_type.to_pandas(), x=\"bedrooms\", y=\"count\", color=\"property_type\",\n",
- " barmode=\"group\", title=\"Bedroom Distribution by Property Type\")\n",
+ "fig = px.bar(\n",
+ " beds_by_type.to_pandas(),\n",
+ " x=\"bedrooms\",\n",
+ " y=\"count\",\n",
+ " color=\"property_type\",\n",
+ " barmode=\"group\",\n",
+ " title=\"Bedroom Distribution by Property Type\",\n",
+ ")\n",
"fig.update_layout(height=450)\n",
"fig.show()"
]
@@ -446323,19 +446378,26 @@
],
"source": [
"# Floorspace availability by property type\n",
- "has_floor = clean.with_columns(pl.col(\"floorspace_sqm\").is_not_null().alias(\"has_floorspace\"))\n",
- "floor_by_type = (\n",
- " has_floor.group_by(\"property_type\", \"has_floorspace\")\n",
- " .agg(pl.len().alias(\"count\"))\n",
+ "has_floor = clean.with_columns(\n",
+ " pl.col(\"floorspace_sqm\").is_not_null().alias(\"has_floorspace\")\n",
+ ")\n",
+ "floor_by_type = has_floor.group_by(\"property_type\", \"has_floorspace\").agg(\n",
+ " pl.len().alias(\"count\")\n",
+ ")\n",
+ "totals = floor_by_type.group_by(\"property_type\").agg(\n",
+ " pl.col(\"count\").sum().alias(\"total\")\n",
")\n",
- "totals = floor_by_type.group_by(\"property_type\").agg(pl.col(\"count\").sum().alias(\"total\"))\n",
"floor_pct = (\n",
" floor_by_type.filter(pl.col(\"has_floorspace\"))\n",
" .join(totals, on=\"property_type\")\n",
- " .with_columns((pl.col(\"count\") / pl.col(\"total\") * 100).alias(\"pct_with_floorspace\"))\n",
+ " .with_columns(\n",
+ " (pl.col(\"count\") / pl.col(\"total\") * 100).alias(\"pct_with_floorspace\")\n",
+ " )\n",
" .sort(\"pct_with_floorspace\", descending=True)\n",
")\n",
- "print(f\"Overall floorspace availability: {clean['floorspace_sqm'].drop_nulls().len():,} / {len(clean):,} ({clean['floorspace_sqm'].drop_nulls().len()/len(clean)*100:.1f}%)\")\n",
+ "print(\n",
+ " f\"Overall floorspace availability: {clean['floorspace_sqm'].drop_nulls().len():,} / {len(clean):,} ({clean['floorspace_sqm'].drop_nulls().len() / len(clean) * 100:.1f}%)\"\n",
+ ")\n",
"floor_pct.select(\"property_type\", \"count\", \"total\", \"pct_with_floorspace\")"
]
},
@@ -447298,8 +447360,13 @@
")\n",
"print(f\"Properties with reasonable floorspace (10-1000 sqm): {len(with_floor):,}\")\n",
"\n",
- "fig = px.histogram(with_floor.to_pandas(), x=\"floorspace_sqm\", nbins=80, color=\"property_type\",\n",
- " title=\"Floorspace Distribution by Property Type\")\n",
+ "fig = px.histogram(\n",
+ " with_floor.to_pandas(),\n",
+ " x=\"floorspace_sqm\",\n",
+ " nbins=80,\n",
+ " color=\"property_type\",\n",
+ " title=\"Floorspace Distribution by Property Type\",\n",
+ ")\n",
"fig.update_layout(height=450, xaxis_title=\"Floorspace (sqm)\", barmode=\"overlay\")\n",
"fig.update_traces(opacity=0.6)\n",
"fig.show()"
@@ -448176,8 +448243,12 @@
"print(f\" P25: £{s.quantile(0.25):,.0f}/sqm\")\n",
"print(f\" P75: £{s.quantile(0.75):,.0f}/sqm\")\n",
"\n",
- "fig = px.histogram(ppsqm.to_pandas(), x=\"price_per_sqm\", nbins=80,\n",
- " title=\"Price per Square Metre Distribution\")\n",
+ "fig = px.histogram(\n",
+ " ppsqm.to_pandas(),\n",
+ " x=\"price_per_sqm\",\n",
+ " nbins=80,\n",
+ " title=\"Price per Square Metre Distribution\",\n",
+ ")\n",
"fig.update_layout(height=400, xaxis_title=\"Price per sqm (£)\")\n",
"fig.show()"
]
@@ -584906,8 +584977,13 @@
}
],
"source": [
- "fig = px.box(ppsqm.to_pandas(), x=\"property_type\", y=\"price_per_sqm\", color=\"property_type\",\n",
- " title=\"Price per sqm by Property Type\")\n",
+ "fig = px.box(\n",
+ " ppsqm.to_pandas(),\n",
+ " x=\"property_type\",\n",
+ " y=\"price_per_sqm\",\n",
+ " color=\"property_type\",\n",
+ " title=\"Price per sqm by Property Type\",\n",
+ ")\n",
"fig.update_layout(height=450, showlegend=False, yaxis_title=\"£ per sqm\")\n",
"fig.show()"
]
@@ -585865,9 +585941,15 @@
")\n",
"\n",
"top30 = outcode_stats.head(30)\n",
- "fig = px.bar(top30.to_pandas(), x=\"count\", y=\"outcode\", orientation=\"h\",\n",
- " color=\"median_price\", color_continuous_scale=\"Viridis\",\n",
- " title=\"Top 30 Outcodes by Listing Volume\")\n",
+ "fig = px.bar(\n",
+ " top30.to_pandas(),\n",
+ " x=\"count\",\n",
+ " y=\"outcode\",\n",
+ " orientation=\"h\",\n",
+ " color=\"median_price\",\n",
+ " color_continuous_scale=\"Viridis\",\n",
+ " title=\"Top 30 Outcodes by Listing Volume\",\n",
+ ")\n",
"fig.update_layout(height=700, yaxis={\"categoryorder\": \"total ascending\"})\n",
"fig.show()"
]
@@ -587400,11 +587482,25 @@
],
"source": [
"# Most expensive outcodes (min 50 listings)\n",
- "expensive = outcode_stats.filter(pl.col(\"count\") >= 50).sort(\"median_price\", descending=True).head(30)\n",
- "fig = px.bar(expensive.to_pandas(), x=\"median_price\", y=\"outcode\", orientation=\"h\",\n",
- " color=\"count\", color_continuous_scale=\"Blues\",\n",
- " title=\"Top 30 Most Expensive Outcodes (min 50 listings, by median price)\")\n",
- "fig.update_layout(height=700, yaxis={\"categoryorder\": \"total ascending\"}, xaxis_title=\"Median Price (£)\")\n",
+ "expensive = (\n",
+ " outcode_stats.filter(pl.col(\"count\") >= 50)\n",
+ " .sort(\"median_price\", descending=True)\n",
+ " .head(30)\n",
+ ")\n",
+ "fig = px.bar(\n",
+ " expensive.to_pandas(),\n",
+ " x=\"median_price\",\n",
+ " y=\"outcode\",\n",
+ " orientation=\"h\",\n",
+ " color=\"count\",\n",
+ " color_continuous_scale=\"Blues\",\n",
+ " title=\"Top 30 Most Expensive Outcodes (min 50 listings, by median price)\",\n",
+ ")\n",
+ "fig.update_layout(\n",
+ " height=700,\n",
+ " yaxis={\"categoryorder\": \"total ascending\"},\n",
+ " xaxis_title=\"Median Price (£)\",\n",
+ ")\n",
"fig.show()"
]
},
@@ -588914,10 +589010,20 @@
"source": [
"# Cheapest outcodes (min 50 listings)\n",
"cheapest = outcode_stats.filter(pl.col(\"count\") >= 50).sort(\"median_price\").head(30)\n",
- "fig = px.bar(cheapest.to_pandas(), x=\"median_price\", y=\"outcode\", orientation=\"h\",\n",
- " color=\"count\", color_continuous_scale=\"Blues\",\n",
- " title=\"Top 30 Cheapest Outcodes (min 50 listings, by median price)\")\n",
- "fig.update_layout(height=700, yaxis={\"categoryorder\": \"total descending\"}, xaxis_title=\"Median Price (£)\")\n",
+ "fig = px.bar(\n",
+ " cheapest.to_pandas(),\n",
+ " x=\"median_price\",\n",
+ " y=\"outcode\",\n",
+ " orientation=\"h\",\n",
+ " color=\"count\",\n",
+ " color_continuous_scale=\"Blues\",\n",
+ " title=\"Top 30 Cheapest Outcodes (min 50 listings, by median price)\",\n",
+ ")\n",
+ "fig.update_layout(\n",
+ " height=700,\n",
+ " yaxis={\"categoryorder\": \"total descending\"},\n",
+ " xaxis_title=\"Median Price (£)\",\n",
+ ")\n",
"fig.show()"
]
},
@@ -589828,14 +589934,19 @@
"source": [
"# Geographic scatter of listings (sample for performance)\n",
"sample = clean.sample(n=min(20_000, len(clean)), seed=42)\n",
- "fig = px.scatter_map(sample.to_pandas(),\n",
- " lat=\"latitude\", lon=\"longitude\",\n",
- " color=\"price\", size_max=4,\n",
- " color_continuous_scale=\"Viridis\",\n",
- " range_color=[100_000, 1_500_000],\n",
- " zoom=5, center={\"lat\": 52.5, \"lon\": -1.5},\n",
- " title=\"Listing Locations (20k sample, colored by price)\",\n",
- " opacity=0.4)\n",
+ "fig = px.scatter_map(\n",
+ " sample.to_pandas(),\n",
+ " lat=\"latitude\",\n",
+ " lon=\"longitude\",\n",
+ " color=\"price\",\n",
+ " size_max=4,\n",
+ " color_continuous_scale=\"Viridis\",\n",
+ " range_color=[100_000, 1_500_000],\n",
+ " zoom=5,\n",
+ " center={\"lat\": 52.5, \"lon\": -1.5},\n",
+ " title=\"Listing Locations (20k sample, colored by price)\",\n",
+ " opacity=0.4,\n",
+ ")\n",
"fig.update_layout(height=700)\n",
"fig.show()"
]
@@ -589864,7 +589975,9 @@
"source": [
"# Parse dates and look at listing age\n",
"with_dates = clean.with_columns(\n",
- " pl.col(\"first_visible_date\").str.to_datetime(\"%Y-%m-%dT%H:%M:%SZ\").alias(\"listed_at\"),\n",
+ " pl.col(\"first_visible_date\")\n",
+ " .str.to_datetime(\"%Y-%m-%dT%H:%M:%SZ\")\n",
+ " .alias(\"listed_at\"),\n",
")\n",
"\n",
"print(f\"Date range: {with_dates['listed_at'].min()} to {with_dates['listed_at'].max()}\")"
@@ -590856,8 +590969,9 @@
" .sort(\"month\")\n",
")\n",
"\n",
- "fig = px.bar(monthly.to_pandas(), x=\"month\", y=\"count\",\n",
- " title=\"Listings by Month Listed\")\n",
+ "fig = px.bar(\n",
+ " monthly.to_pandas(), x=\"month\", y=\"count\", title=\"Listings by Month Listed\"\n",
+ ")\n",
"fig.update_layout(height=400, xaxis_title=\"Month\", yaxis_title=\"Listings\")\n",
"fig.show()"
]
@@ -590884,6 +590998,7 @@
"source": [
"# How old are current listings? (days since first visible)\n",
"import datetime\n",
+ "\n",
"now = datetime.datetime(2026, 2, 14)\n",
"with_age = with_dates.with_columns(\n",
" ((pl.lit(now) - pl.col(\"listed_at\")).dt.total_days()).alias(\"days_on_market\")\n",
@@ -590896,7 +591011,7 @@
"print(f\" P25: {age.quantile(0.25):.0f} days\")\n",
"print(f\" P75: {age.quantile(0.75):.0f} days\")\n",
"print(f\" P95: {age.quantile(0.95):.0f} days\")\n",
- "print(f\" Max: {age.max():.0f} days ({age.max()/365:.1f} years)\")"
+ "print(f\" Max: {age.max():.0f} days ({age.max() / 365:.1f} years)\")"
]
},
{
@@ -591749,8 +591864,12 @@
"source": [
"# Days on market distribution (cap at 2 years for readability)\n",
"capped = with_age.filter(pl.col(\"days_on_market\") <= 730)\n",
- "fig = px.histogram(capped.to_pandas(), x=\"days_on_market\", nbins=100,\n",
- " title=\"Days on Market Distribution (capped at 2 years)\")\n",
+ "fig = px.histogram(\n",
+ " capped.to_pandas(),\n",
+ " x=\"days_on_market\",\n",
+ " nbins=100,\n",
+ " title=\"Days on Market Distribution (capped at 2 years)\",\n",
+ ")\n",
"fig.update_layout(height=400, xaxis_title=\"Days on Market\", yaxis_title=\"Count\")\n",
"fig.show()"
]
@@ -591883,11 +592002,13 @@
"# Explode features list and count most common\n",
"features_exploded = clean.select(\"features\").explode(\"features\").drop_nulls()\n",
"print(f\"Total feature entries: {len(features_exploded):,}\")\n",
- "print(f\"Features per listing: {len(features_exploded)/len(clean):.1f} avg\")\n",
+ "print(f\"Features per listing: {len(features_exploded) / len(clean):.1f} avg\")\n",
"\n",
"# Most common features (lowercased for grouping)\n",
"feature_counts = (\n",
- " features_exploded.with_columns(pl.col(\"features\").str.to_lowercase().str.strip_chars().alias(\"feature_lower\"))\n",
+ " features_exploded.with_columns(\n",
+ " pl.col(\"features\").str.to_lowercase().str.strip_chars().alias(\"feature_lower\")\n",
+ " )\n",
" .group_by(\"feature_lower\")\n",
" .agg(pl.len().alias(\"count\"))\n",
" .sort(\"count\", descending=True)\n",
@@ -592794,16 +592915,64 @@
"all_features = features_exploded[\"features\"].to_list()\n",
"word_counter = Counter()\n",
"for feat in all_features:\n",
- " words = re.findall(r'[a-z]+', feat.lower())\n",
+ " words = re.findall(r\"[a-z]+\", feat.lower())\n",
" word_counter.update(words)\n",
"\n",
"# Filter out very short/common words\n",
- "stop_words = {'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'with', 'for', 'on', 'at', 'by', 'is', 'it', 'from', 'as', 'be', 'this', 'that', 'are', 'was', 'has', 'have', 'not', 'but', 'all', 'can', 'had', 'her', 'his', 'one', 'our', 'out', 'you', 'will'}\n",
- "keywords = [(w, c) for w, c in word_counter.most_common(100) if w not in stop_words and len(w) > 2]\n",
- "kw_df = pl.DataFrame({\"word\": [w for w,c in keywords[:40]], \"count\": [c for w,c in keywords[:40]]})\n",
+ "stop_words = {\n",
+ " \"the\",\n",
+ " \"a\",\n",
+ " \"an\",\n",
+ " \"and\",\n",
+ " \"or\",\n",
+ " \"of\",\n",
+ " \"to\",\n",
+ " \"in\",\n",
+ " \"with\",\n",
+ " \"for\",\n",
+ " \"on\",\n",
+ " \"at\",\n",
+ " \"by\",\n",
+ " \"is\",\n",
+ " \"it\",\n",
+ " \"from\",\n",
+ " \"as\",\n",
+ " \"be\",\n",
+ " \"this\",\n",
+ " \"that\",\n",
+ " \"are\",\n",
+ " \"was\",\n",
+ " \"has\",\n",
+ " \"have\",\n",
+ " \"not\",\n",
+ " \"but\",\n",
+ " \"all\",\n",
+ " \"can\",\n",
+ " \"had\",\n",
+ " \"her\",\n",
+ " \"his\",\n",
+ " \"one\",\n",
+ " \"our\",\n",
+ " \"out\",\n",
+ " \"you\",\n",
+ " \"will\",\n",
+ "}\n",
+ "keywords = [\n",
+ " (w, c)\n",
+ " for w, c in word_counter.most_common(100)\n",
+ " if w not in stop_words and len(w) > 2\n",
+ "]\n",
+ "kw_df = pl.DataFrame(\n",
+ " {\"word\": [w for w, c in keywords[:40]], \"count\": [c for w, c in keywords[:40]]}\n",
+ ")\n",
"\n",
- "fig = px.bar(kw_df.to_pandas(), x=\"count\", y=\"word\", orientation=\"h\",\n",
- " title=\"Most Common Words in Feature Descriptions\")\n",
+ "fig = px.bar(\n",
+ " kw_df.to_pandas(),\n",
+ " x=\"count\",\n",
+ " y=\"word\",\n",
+ " orientation=\"h\",\n",
+ " title=\"Most Common Words in Feature Descriptions\",\n",
+ ")\n",
"fig.update_layout(height=800, yaxis={\"categoryorder\": \"total ascending\"})\n",
"fig.show()"
]
@@ -593767,9 +593936,14 @@
" & (pl.col(\"price\") < 3_000_000)\n",
").sample(n=min(15_000, len(with_floor)), seed=42)\n",
"\n",
- "fig = px.scatter(scatter_df.to_pandas(), x=\"floorspace_sqm\", y=\"price\",\n",
- " color=\"property_type\", opacity=0.3,\n",
- " title=\"Price vs Floorspace (sample, capped at £3M / 500sqm)\")\n",
+ "fig = px.scatter(\n",
+ " scatter_df.to_pandas(),\n",
+ " x=\"floorspace_sqm\",\n",
+ " y=\"price\",\n",
+ " color=\"property_type\",\n",
+ " opacity=0.3,\n",
+ " title=\"Price vs Floorspace (sample, capped at £3M / 500sqm)\",\n",
+ ")\n",
"fig.update_layout(height=600, xaxis_title=\"Floorspace (sqm)\", yaxis_title=\"Price (£)\")\n",
"fig.show()"
]
@@ -594739,8 +594913,14 @@
" .agg(pl.col(\"price\").median().alias(\"median_price\"), pl.len().alias(\"count\"))\n",
" .sort(\"property_type\", \"bedrooms\")\n",
")\n",
- "fig = px.line(bp.to_pandas(), x=\"bedrooms\", y=\"median_price\", color=\"property_type\",\n",
- " markers=True, title=\"Median Price by Bedrooms and Property Type\")\n",
+ "fig = px.line(\n",
+ " bp.to_pandas(),\n",
+ " x=\"bedrooms\",\n",
+ " y=\"median_price\",\n",
+ " color=\"property_type\",\n",
+ " markers=True,\n",
+ " title=\"Median Price by Bedrooms and Property Type\",\n",
+ ")\n",
"fig.update_layout(height=450, xaxis_title=\"Bedrooms\", yaxis_title=\"Median Price (£)\")\n",
"fig.show()"
]
@@ -594789,18 +594969,28 @@
"print(f\"Total listings: {len(clean):,}\")\n",
"print(f\"Outcodes covered: {clean['outcode'].n_unique():,}\")\n",
"print(\"\")\n",
- "print(f\"Price: median £{clean['price'].median():,.0f}, mean £{clean['price'].mean():,.0f}\")\n",
- "print(f\"Bedrooms: median {clean['bedrooms'].median():.0f}, mean {clean['bedrooms'].mean():.1f}\")\n",
+ "print(\n",
+ " f\"Price: median £{clean['price'].median():,.0f}, mean £{clean['price'].mean():,.0f}\"\n",
+ ")\n",
+ "print(\n",
+ " f\"Bedrooms: median {clean['bedrooms'].median():.0f}, mean {clean['bedrooms'].mean():.1f}\"\n",
+ ")\n",
"print(\"\")\n",
- "print(f\"Tenure known: {(len(clean) - clean['tenure'].null_count())/len(clean)*100:.1f}%\")\n",
+ "print(\n",
+ " f\"Tenure known: {(len(clean) - clean['tenure'].null_count()) / len(clean) * 100:.1f}%\"\n",
+ ")\n",
"print(f\" Freehold: {len(clean.filter(pl.col('tenure') == 'Freehold')):,}\")\n",
"print(f\" Leasehold: {len(clean.filter(pl.col('tenure') == 'Leasehold')):,}\")\n",
"print(\"\")\n",
- "print(f\"Floorspace available: {clean['floorspace_sqm'].drop_nulls().len()/len(clean)*100:.1f}%\")\n",
+ "print(\n",
+ " f\"Floorspace available: {clean['floorspace_sqm'].drop_nulls().len() / len(clean) * 100:.1f}%\"\n",
+ ")\n",
"print(\"\")\n",
"print(\"Property types:\")\n",
- "for row in clean['property_type'].value_counts().sort('count', descending=True).iter_rows():\n",
- " print(f\" {row[0]}: {row[1]:,} ({row[1]/len(clean)*100:.1f}%)\")"
+ "for row in (\n",
+ " clean[\"property_type\"].value_counts().sort(\"count\", descending=True).iter_rows()\n",
+ "):\n",
+ " print(f\" {row[0]}: {row[1]:,} ({row[1] / len(clean) * 100:.1f}%)\")"
]
}
],
diff --git a/analyses/source_overlap.ipynb b/analyses/source_overlap.ipynb
index 399ab3a..45621b9 100644
--- a/analyses/source_overlap.ipynb
+++ b/analyses/source_overlap.ipynb
@@ -52,6 +52,7 @@
"buy = pl.read_parquet(f\"{DATA}/online_listings_buy.parquet\")\n",
"rent = pl.read_parquet(f\"{DATA}/online_listings_rent.parquet\")\n",
"\n",
+ "\n",
"def tag_source(df: pl.DataFrame) -> pl.DataFrame:\n",
" return df.with_columns(\n",
" pl.when(pl.col(\"Listing URL\").str.contains(\"rightmove\"))\n",
@@ -62,6 +63,7 @@
" .alias(\"source\")\n",
" )\n",
"\n",
+ "\n",
"buy = tag_source(buy)\n",
"rent = tag_source(rent)\n",
"\n",
@@ -122,7 +124,7 @@
" print(f\"\\n=== {label} ===\")\n",
" for row in counts.iter_rows():\n",
" src, cnt = row\n",
- " print(f\" {src}: {cnt:,} ({cnt/len(df)*100:.1f}%)\")\n",
+ " print(f\" {src}: {cnt:,} ({cnt / len(df) * 100:.1f}%)\")\n",
"\n",
"# Known dedup count from scraper logs\n",
"CROSS_DEDUP_BUY = 2_220\n",
@@ -132,7 +134,7 @@
"print(f\"Home.co.uk scraped (before dedup): {hk_buy_total:,}\")\n",
"print(f\"Home.co.uk unique (after dedup): {hk_buy_unique:,}\")\n",
"print(f\"Cross-source duplicates removed: {CROSS_DEDUP_BUY:,}\")\n",
- "print(f\"Overlap rate: {CROSS_DEDUP_BUY/hk_buy_total*100:.1f}%\")"
+ "print(f\"Overlap rate: {CROSS_DEDUP_BUY / hk_buy_total * 100:.1f}%\")"
]
},
{
@@ -987,23 +989,29 @@
"# Venn-style summary\n",
"rm_buy = len(buy.filter(pl.col(\"source\") == \"Rightmove\"))\n",
"\n",
- "fig = go.Figure(go.Sankey(\n",
- " node=dict(\n",
- " label=[\n",
- " f\"Rightmove\\n{rm_buy:,}\",\n",
- " f\"Home.co.uk\\n{hk_buy_total:,} scraped\",\n",
- " f\"Merged BUY\\n{len(buy):,}\",\n",
- " f\"Deduped\\n{CROSS_DEDUP_BUY:,}\",\n",
- " ],\n",
- " color=[\"#2563eb\", \"#10b981\", \"#6366f1\", \"#ef4444\"],\n",
- " ),\n",
- " link=dict(\n",
- " source=[0, 1, 1],\n",
- " target=[2, 2, 3],\n",
- " value=[rm_buy, hk_buy_unique, CROSS_DEDUP_BUY],\n",
- " color=[\"rgba(37,99,235,0.3)\", \"rgba(16,185,129,0.3)\", \"rgba(239,68,68,0.3)\"],\n",
- " ),\n",
- "))\n",
+ "fig = go.Figure(\n",
+ " go.Sankey(\n",
+ " node=dict(\n",
+ " label=[\n",
+ " f\"Rightmove\\n{rm_buy:,}\",\n",
+ " f\"Home.co.uk\\n{hk_buy_total:,} scraped\",\n",
+ " f\"Merged BUY\\n{len(buy):,}\",\n",
+ " f\"Deduped\\n{CROSS_DEDUP_BUY:,}\",\n",
+ " ],\n",
+ " color=[\"#2563eb\", \"#10b981\", \"#6366f1\", \"#ef4444\"],\n",
+ " ),\n",
+ " link=dict(\n",
+ " source=[0, 1, 1],\n",
+ " target=[2, 2, 3],\n",
+ " value=[rm_buy, hk_buy_unique, CROSS_DEDUP_BUY],\n",
+ " color=[\n",
+ " \"rgba(37,99,235,0.3)\",\n",
+ " \"rgba(16,185,129,0.3)\",\n",
+ " \"rgba(239,68,68,0.3)\",\n",
+ " ],\n",
+ " ),\n",
+ " )\n",
+ ")\n",
"fig.update_layout(title=\"BUY Channel: Source Contribution Flow\", height=350)\n",
"fig.show()"
]
@@ -1106,8 +1114,11 @@
"oc_comparison = (\n",
" hk_by_oc.join(rm_by_oc, on=\"outcode\", how=\"left\")\n",
" .with_columns(\n",
- " (pl.col(\"hk_count\") / (pl.col(\"hk_count\") + pl.col(\"rm_count\").fill_null(0)) * 100)\n",
- " .alias(\"hk_pct_of_total\")\n",
+ " (\n",
+ " pl.col(\"hk_count\")\n",
+ " / (pl.col(\"hk_count\") + pl.col(\"rm_count\").fill_null(0))\n",
+ " * 100\n",
+ " ).alias(\"hk_pct_of_total\")\n",
" )\n",
" .sort(\"hk_count\", descending=True)\n",
")\n",
@@ -2215,18 +2226,28 @@
"source": [
"# Bar chart: home.co.uk vs Rightmove counts per outcode\n",
"fig = go.Figure()\n",
- "fig.add_trace(go.Bar(\n",
- " x=oc_comparison[\"outcode\"], y=oc_comparison[\"rm_count\"],\n",
- " name=\"Rightmove\", marker_color=\"#2563eb\",\n",
- "))\n",
- "fig.add_trace(go.Bar(\n",
- " x=oc_comparison[\"outcode\"], y=oc_comparison[\"hk_count\"],\n",
- " name=\"Home.co.uk\", marker_color=\"#10b981\",\n",
- "))\n",
+ "fig.add_trace(\n",
+ " go.Bar(\n",
+ " x=oc_comparison[\"outcode\"],\n",
+ " y=oc_comparison[\"rm_count\"],\n",
+ " name=\"Rightmove\",\n",
+ " marker_color=\"#2563eb\",\n",
+ " )\n",
+ ")\n",
+ "fig.add_trace(\n",
+ " go.Bar(\n",
+ " x=oc_comparison[\"outcode\"],\n",
+ " y=oc_comparison[\"hk_count\"],\n",
+ " name=\"Home.co.uk\",\n",
+ " marker_color=\"#10b981\",\n",
+ " )\n",
+ ")\n",
"fig.update_layout(\n",
- " barmode=\"group\", height=400,\n",
+ " barmode=\"group\",\n",
+ " height=400,\n",
" title=\"Listings per Outcode: Rightmove vs Home.co.uk (outcodes with HK coverage)\",\n",
- " xaxis_title=\"Outcode\", yaxis_title=\"Listings\",\n",
+ " xaxis_title=\"Outcode\",\n",
+ " yaxis_title=\"Listings\",\n",
")\n",
"fig.show()"
]
@@ -3121,10 +3142,14 @@
"sample = covered.sample(n=min(30_000, len(covered)), seed=42)\n",
"\n",
"fig = px.scatter_map(\n",
- " sample.to_pandas(), lat=\"lat\", lon=\"lon\",\n",
+ " sample.to_pandas(),\n",
+ " lat=\"lat\",\n",
+ " lon=\"lon\",\n",
" color=\"source\",\n",
" color_discrete_map={\"Rightmove\": \"#2563eb\", \"Home.co.uk\": \"#10b981\"},\n",
- " zoom=7, opacity=0.4, size_max=4,\n",
+ " zoom=7,\n",
+ " opacity=0.4,\n",
+ " size_max=4,\n",
" title=\"Listing Locations in Covered Outcodes (by source)\",\n",
")\n",
"fig.update_layout(height=600)\n",
@@ -3188,15 +3213,41 @@
"# For covered outcodes, compare home.co.uk listings against Rightmove\n",
"# to find near-matches (same postcode, same beds, price within 5%)\n",
"\n",
- "hk = buy_oc.filter(pl.col(\"source\") == \"Home.co.uk\").select(\n",
- " \"Postcode\", \"Bedrooms\", \"Asking price\", \"Property type\", \"Address per Property Register\"\n",
- ").rename({\"Asking price\": \"hk_price\", \"Property type\": \"hk_type\", \"Address per Property Register\": \"hk_addr\"})\n",
+ "hk = (\n",
+ " buy_oc.filter(pl.col(\"source\") == \"Home.co.uk\")\n",
+ " .select(\n",
+ " \"Postcode\",\n",
+ " \"Bedrooms\",\n",
+ " \"Asking price\",\n",
+ " \"Property type\",\n",
+ " \"Address per Property Register\",\n",
+ " )\n",
+ " .rename(\n",
+ " {\n",
+ " \"Asking price\": \"hk_price\",\n",
+ " \"Property type\": \"hk_type\",\n",
+ " \"Address per Property Register\": \"hk_addr\",\n",
+ " }\n",
+ " )\n",
+ ")\n",
"\n",
- "rm = buy_oc.filter(\n",
- " pl.col(\"source\") == \"Rightmove\"\n",
- ").select(\n",
- " \"Postcode\", \"Bedrooms\", \"Asking price\", \"Property type\", \"Address per Property Register\"\n",
- ").rename({\"Asking price\": \"rm_price\", \"Property type\": \"rm_type\", \"Address per Property Register\": \"rm_addr\"})\n",
+ "rm = (\n",
+ " buy_oc.filter(pl.col(\"source\") == \"Rightmove\")\n",
+ " .select(\n",
+ " \"Postcode\",\n",
+ " \"Bedrooms\",\n",
+ " \"Asking price\",\n",
+ " \"Property type\",\n",
+ " \"Address per Property Register\",\n",
+ " )\n",
+ " .rename(\n",
+ " {\n",
+ " \"Asking price\": \"rm_price\",\n",
+ " \"Property type\": \"rm_type\",\n",
+ " \"Address per Property Register\": \"rm_addr\",\n",
+ " }\n",
+ " )\n",
+ ")\n",
"\n",
"# Join on postcode + bedrooms\n",
"joined = hk.join(rm, on=[\"Postcode\", \"Bedrooms\"], how=\"inner\")\n",
@@ -3213,16 +3264,24 @@
"exact = joined.filter(pl.col(\"hk_price\") == pl.col(\"rm_price\"))\n",
"\n",
"print(f\"Home.co.uk listings (unique, in file): {len(hk):,}\")\n",
- "print(f\"Rightmove listings in covered outcodes: {len(rm.filter(pl.col('Postcode').is_in(hk['Postcode']))):,}\")\n",
+ "print(\n",
+ " f\"Rightmove listings in covered outcodes: {len(rm.filter(pl.col('Postcode').is_in(hk['Postcode']))):,}\"\n",
+ ")\n",
"print()\n",
"print(f\"Joined on (postcode, bedrooms): {len(joined):,} candidate pairs\")\n",
- "print(f\" Exact price match: {len(exact):,} pairs (likely same property, different beds or already deduped)\")\n",
- "print(f\" Price within 5%: {len(near):,} pairs (probable duplicates with price rounding)\")\n",
+ "print(\n",
+ " f\" Exact price match: {len(exact):,} pairs (likely same property, different beds or already deduped)\"\n",
+ ")\n",
+ "print(\n",
+ " f\" Price within 5%: {len(near):,} pairs (probable duplicates with price rounding)\"\n",
+ ")\n",
"print()\n",
"# Unique hk listings that have at least one near-match\n",
"hk_with_near = near.select(\"hk_price\", \"hk_addr\", \"Postcode\").unique()\n",
"print(f\"Home.co.uk listings with a near-match in RM: ~{len(hk_with_near):,}\")\n",
- "print(f\"Estimated additional overlap: ~{len(hk_with_near)/len(hk)*100:.1f}% of unique HK listings\")"
+ "print(\n",
+ " f\"Estimated additional overlap: ~{len(hk_with_near) / len(hk) * 100:.1f}% of unique HK listings\"\n",
+ ")"
]
},
{
@@ -4178,9 +4237,13 @@
")\n",
"\n",
"fig = px.histogram(\n",
- " clipped.to_pandas(), x=\"Asking price\", color=\"source\", nbins=80,\n",
+ " clipped.to_pandas(),\n",
+ " x=\"Asking price\",\n",
+ " color=\"source\",\n",
+ " nbins=80,\n",
" color_discrete_map={\"Rightmove\": \"#2563eb\", \"Home.co.uk\": \"#10b981\"},\n",
- " barmode=\"overlay\", histnorm=\"probability density\",\n",
+ " barmode=\"overlay\",\n",
+ " histnorm=\"probability density\",\n",
" title=\"Price Distribution by Source (normalised, £50k–£2M)\",\n",
")\n",
"fig.update_traces(opacity=0.6)\n",
@@ -5095,10 +5158,7 @@
],
"source": [
"# Property type distribution by source\n",
- "type_by_src = (\n",
- " buy.group_by(\"source\", \"Property type\")\n",
- " .agg(pl.len().alias(\"count\"))\n",
- ")\n",
+ "type_by_src = buy.group_by(\"source\", \"Property type\").agg(pl.len().alias(\"count\"))\n",
"# Normalise within each source\n",
"totals = type_by_src.group_by(\"source\").agg(pl.col(\"count\").sum().alias(\"total\"))\n",
"type_by_src = type_by_src.join(totals, on=\"source\").with_columns(\n",
@@ -5107,7 +5167,10 @@
"\n",
"fig = px.bar(\n",
" type_by_src.sort(\"Property type\").to_pandas(),\n",
- " x=\"Property type\", y=\"pct\", color=\"source\", barmode=\"group\",\n",
+ " x=\"Property type\",\n",
+ " y=\"pct\",\n",
+ " color=\"source\",\n",
+ " barmode=\"group\",\n",
" color_discrete_map={\"Rightmove\": \"#2563eb\", \"Home.co.uk\": \"#10b981\"},\n",
" title=\"Property Type Distribution by Source (%)\",\n",
")\n",
@@ -5186,7 +5249,9 @@
"# Property sub-type comparison — top home.co.uk sub-types\n",
"hk_subtypes = (\n",
" buy.filter(pl.col(\"source\") == \"Home.co.uk\")[\"Property sub-type\"]\n",
- " .value_counts().sort(\"count\", descending=True).head(20)\n",
+ " .value_counts()\n",
+ " .sort(\"count\", descending=True)\n",
+ " .head(20)\n",
")\n",
"print(\"Top 20 Home.co.uk property sub-types:\")\n",
"hk_subtypes"
@@ -5263,9 +5328,16 @@
"source": [
"# Field completeness by source\n",
"fields = [\n",
- " \"Bedrooms\", \"Bathrooms\", \"Postcode\", \"Address per Property Register\",\n",
- " \"Leasehold/Freehold\", \"Property type\", \"Total floor area (sqm)\",\n",
- " \"Listing date\", \"Asking price\", \"Price qualifier\",\n",
+ " \"Bedrooms\",\n",
+ " \"Bathrooms\",\n",
+ " \"Postcode\",\n",
+ " \"Address per Property Register\",\n",
+ " \"Leasehold/Freehold\",\n",
+ " \"Property type\",\n",
+ " \"Total floor area (sqm)\",\n",
+ " \"Listing date\",\n",
+ " \"Asking price\",\n",
+ " \"Price qualifier\",\n",
"]\n",
"\n",
"rows = []\n",
@@ -5276,17 +5348,19 @@
" non_null = n - subset[f].null_count()\n",
" # Also count empty strings as missing for string fields\n",
" if subset[f].dtype == pl.Utf8:\n",
- " non_null = len(subset.filter(\n",
- " pl.col(f).is_not_null() & (pl.col(f).str.len_chars() > 0)\n",
- " ))\n",
+ " non_null = len(\n",
+ " subset.filter(pl.col(f).is_not_null() & (pl.col(f).str.len_chars() > 0))\n",
+ " )\n",
" rows.append({\"source\": src, \"field\": f, \"pct_available\": non_null / n * 100})\n",
"\n",
"completeness = pl.DataFrame(rows)\n",
"pivot = completeness.pivot(on=\"source\", index=\"field\", values=\"pct_available\")\n",
- "pivot = pivot.with_columns([\n",
- " pl.col(\"Rightmove\").round(1),\n",
- " pl.col(\"Home.co.uk\").round(1),\n",
- "])\n",
+ "pivot = pivot.with_columns(\n",
+ " [\n",
+ " pl.col(\"Rightmove\").round(1),\n",
+ " pl.col(\"Home.co.uk\").round(1),\n",
+ " ]\n",
+ ")\n",
"print(\"Field completeness (% non-null/non-empty):\")\n",
"pivot"
]
@@ -6198,19 +6272,26 @@
"# Bedroom distribution comparison\n",
"fig = make_subplots(rows=1, cols=2, subplot_titles=(\"Rightmove\", \"Home.co.uk\"))\n",
"for i, src in enumerate([\"Rightmove\", \"Home.co.uk\"], 1):\n",
- " beds = buy.filter(\n",
- " (pl.col(\"source\") == src) & (pl.col(\"Bedrooms\") <= 8)\n",
- " )[\"Bedrooms\"].value_counts().sort(\"Bedrooms\")\n",
+ " beds = (\n",
+ " buy.filter((pl.col(\"source\") == src) & (pl.col(\"Bedrooms\") <= 8))[\"Bedrooms\"]\n",
+ " .value_counts()\n",
+ " .sort(\"Bedrooms\")\n",
+ " )\n",
" # Normalise\n",
" total = beds[\"count\"].sum()\n",
" fig.add_trace(\n",
" go.Bar(\n",
- " x=beds[\"Bedrooms\"], y=beds[\"count\"] / total * 100,\n",
+ " x=beds[\"Bedrooms\"],\n",
+ " y=beds[\"count\"] / total * 100,\n",
" name=src,\n",
" marker_color=\"#2563eb\" if src == \"Rightmove\" else \"#10b981\",\n",
- " ), row=1, col=i,\n",
+ " ),\n",
+ " row=1,\n",
+ " col=i,\n",
" )\n",
- "fig.update_layout(height=350, title=\"Bedroom Distribution by Source (%)\", showlegend=False)\n",
+ "fig.update_layout(\n",
+ " height=350, title=\"Bedroom Distribution by Source (%)\", showlegend=False\n",
+ ")\n",
"fig.update_yaxes(title_text=\"%\", row=1, col=1)\n",
"fig.show()"
]
@@ -6287,17 +6368,23 @@
"\n",
"comparison_rows = []\n",
"for ptype in [\"Detached\", \"Semi-Detached\", \"Terraced\", \"Flats/Maisonettes\", \"Other\"]:\n",
- " rm_p = rm_covered.filter(pl.col(\"Property type\") == ptype)[\"Asking price\"].drop_nulls()\n",
+ " rm_p = rm_covered.filter(pl.col(\"Property type\") == ptype)[\n",
+ " \"Asking price\"\n",
+ " ].drop_nulls()\n",
" hk_p = hk_only.filter(pl.col(\"Property type\") == ptype)[\"Asking price\"].drop_nulls()\n",
" if len(rm_p) > 0 and len(hk_p) > 0:\n",
- " comparison_rows.append({\n",
- " \"Property type\": ptype,\n",
- " \"RM count\": len(rm_p),\n",
- " \"RM median £\": int(rm_p.median()),\n",
- " \"HK count\": len(hk_p),\n",
- " \"HK median £\": int(hk_p.median()),\n",
- " \"HK premium %\": round((hk_p.median() - rm_p.median()) / rm_p.median() * 100, 1),\n",
- " })\n",
+ " comparison_rows.append(\n",
+ " {\n",
+ " \"Property type\": ptype,\n",
+ " \"RM count\": len(rm_p),\n",
+ " \"RM median £\": int(rm_p.median()),\n",
+ " \"HK count\": len(hk_p),\n",
+ " \"HK median £\": int(hk_p.median()),\n",
+ " \"HK premium %\": round(\n",
+ " (hk_p.median() - rm_p.median()) / rm_p.median() * 100, 1\n",
+ " ),\n",
+ " }\n",
+ " )\n",
"\n",
"comp = pl.DataFrame(comparison_rows)\n",
"print(\"Price comparison in covered outcodes (Home.co.uk unique listings vs Rightmove):\")\n",
@@ -7245,9 +7332,13 @@
"# Listing age histogram comparison\n",
"age_plot = with_age.filter(pl.col(\"days_on_market\") <= 730) # cap at 2 years\n",
"fig = px.histogram(\n",
- " age_plot.to_pandas(), x=\"days_on_market\", color=\"source\", nbins=60,\n",
+ " age_plot.to_pandas(),\n",
+ " x=\"days_on_market\",\n",
+ " color=\"source\",\n",
+ " nbins=60,\n",
" color_discrete_map={\"Rightmove\": \"#2563eb\", \"Home.co.uk\": \"#10b981\"},\n",
- " barmode=\"overlay\", histnorm=\"probability density\",\n",
+ " barmode=\"overlay\",\n",
+ " histnorm=\"probability density\",\n",
" title=\"Days on Market Distribution by Source (normalised, capped at 2 years)\",\n",
")\n",
"fig.update_traces(opacity=0.6)\n",
@@ -7330,7 +7421,9 @@
"print(f\" Projected home.co.uk total: ~{projected_hk:,}\")\n",
"print(f\" Projected cross-dedup: ~{projected_dedup:,}\")\n",
"print(f\" Projected unique additions: ~{projected_unique:,}\")\n",
- "print(f\" Projected merged dataset: ~{rm_buy + projected_unique:,} ({projected_unique/rm_buy*100:.1f}% increase)\")\n",
+ "print(\n",
+ " f\" Projected merged dataset: ~{rm_buy + projected_unique:,} ({projected_unique / rm_buy * 100:.1f}% increase)\"\n",
+ ")\n",
"print()\n",
"print(\"⚠️ These are rough estimates — the covered outcodes may not be representative\")"
]
diff --git a/analyses/travel_time_comparison.ipynb b/analyses/travel_time_comparison.ipynb
index 7fae5a2..1b2ead8 100644
--- a/analyses/travel_time_comparison.ipynb
+++ b/analyses/travel_time_comparison.ipynb
@@ -54,11 +54,15 @@
}
],
"source": [
- "r5_bank = pl.read_parquet(\"../property-data/travel-times/transit/000000-bank-tube-station.parquet\")\n",
+ "r5_bank = pl.read_parquet(\n",
+ " \"../property-data/travel-times/transit/000000-bank-tube-station.parquet\"\n",
+ ")\n",
"manual_bank = pl.read_parquet(\"../manual-data/journey_times_bank.parquet\")\n",
"\n",
"print(f\"R5 Bank: {r5_bank.shape[0]:,} postcodes\")\n",
- "print(f\"Manual Bank: {manual_bank.shape[0]:,} postcodes ({manual_bank['public_transport_easy_minutes'].null_count():,} null easy)\")"
+ "print(\n",
+ " f\"Manual Bank: {manual_bank.shape[0]:,} postcodes ({manual_bank['public_transport_easy_minutes'].null_count():,} null easy)\"\n",
+ ")"
]
},
{
@@ -116,25 +120,49 @@
"source": [
"# Join on postcode, keep only rows where both sources have values\n",
"bank = (\n",
- " r5_bank\n",
- " .join(manual_bank, left_on=\"pcds\", right_on=\"postcode\", how=\"inner\")\n",
+ " r5_bank.join(manual_bank, left_on=\"pcds\", right_on=\"postcode\", how=\"inner\")\n",
" .filter(\n",
" pl.col(\"public_transport_easy_minutes\").is_not_null()\n",
" & pl.col(\"public_transport_quick_minutes\").is_not_null()\n",
" )\n",
- " .with_columns([\n",
- " # Signed error: R5 - Manual (positive = R5 is slower)\n",
- " (pl.col(\"travel_minutes\").cast(pl.Float64) - pl.col(\"public_transport_easy_minutes\").cast(pl.Float64)).alias(\"error_easy\"),\n",
- " (pl.col(\"best_minutes\").cast(pl.Float64) - pl.col(\"public_transport_quick_minutes\").cast(pl.Float64)).alias(\"error_quick\"),\n",
- " # Absolute error\n",
- " (pl.col(\"travel_minutes\").cast(pl.Float64) - pl.col(\"public_transport_easy_minutes\").cast(pl.Float64)).abs().alias(\"abs_error_easy\"),\n",
- " (pl.col(\"best_minutes\").cast(pl.Float64) - pl.col(\"public_transport_quick_minutes\").cast(pl.Float64)).abs().alias(\"abs_error_quick\"),\n",
- " ])\n",
+ " .with_columns(\n",
+ " [\n",
+ " # Signed error: R5 - Manual (positive = R5 is slower)\n",
+ " (\n",
+ " pl.col(\"travel_minutes\").cast(pl.Float64)\n",
+ " - pl.col(\"public_transport_easy_minutes\").cast(pl.Float64)\n",
+ " ).alias(\"error_easy\"),\n",
+ " (\n",
+ " pl.col(\"best_minutes\").cast(pl.Float64)\n",
+ " - pl.col(\"public_transport_quick_minutes\").cast(pl.Float64)\n",
+ " ).alias(\"error_quick\"),\n",
+ " # Absolute error\n",
+ " (\n",
+ " pl.col(\"travel_minutes\").cast(pl.Float64)\n",
+ " - pl.col(\"public_transport_easy_minutes\").cast(pl.Float64)\n",
+ " )\n",
+ " .abs()\n",
+ " .alias(\"abs_error_easy\"),\n",
+ " (\n",
+ " pl.col(\"best_minutes\").cast(pl.Float64)\n",
+ " - pl.col(\"public_transport_quick_minutes\").cast(pl.Float64)\n",
+ " )\n",
+ " .abs()\n",
+ " .alias(\"abs_error_quick\"),\n",
+ " ]\n",
+ " )\n",
")\n",
"\n",
"print(f\"Joined (non-null): {bank.shape[0]:,} postcodes\")\n",
- "bank.select(\"pcds\", \"travel_minutes\", \"public_transport_easy_minutes\", \"error_easy\",\n",
- " \"best_minutes\", \"public_transport_quick_minutes\", \"error_quick\").head(10)"
+ "bank.select(\n",
+ " \"pcds\",\n",
+ " \"travel_minutes\",\n",
+ " \"public_transport_easy_minutes\",\n",
+ " \"error_easy\",\n",
+ " \"best_minutes\",\n",
+ " \"public_transport_quick_minutes\",\n",
+ " \"error_quick\",\n",
+ ").head(10)"
]
},
{
@@ -196,18 +224,23 @@
" percentiles = [5, 25, 50, 80, 90, 95, 99]\n",
" rows = []\n",
" for p in percentiles:\n",
- " rows.append({\n",
- " \"percentile\": f\"p{p}\",\n",
- " f\"{label} signed error\": round(float(np.percentile(col, p)), 1),\n",
- " f\"{label} absolute error\": round(float(np.percentile(abs_col, p)), 1),\n",
- " })\n",
- " rows.append({\n",
- " \"percentile\": \"mean\",\n",
- " f\"{label} signed error\": round(float(np.mean(col)), 1),\n",
- " f\"{label} absolute error\": round(float(np.mean(abs_col)), 1),\n",
- " })\n",
+ " rows.append(\n",
+ " {\n",
+ " \"percentile\": f\"p{p}\",\n",
+ " f\"{label} signed error\": round(float(np.percentile(col, p)), 1),\n",
+ " f\"{label} absolute error\": round(float(np.percentile(abs_col, p)), 1),\n",
+ " }\n",
+ " )\n",
+ " rows.append(\n",
+ " {\n",
+ " \"percentile\": \"mean\",\n",
+ " f\"{label} signed error\": round(float(np.mean(col)), 1),\n",
+ " f\"{label} absolute error\": round(float(np.mean(abs_col)), 1),\n",
+ " }\n",
+ " )\n",
" return pl.DataFrame(rows)\n",
"\n",
+ "\n",
"stats_easy = percentile_stats(\"error_easy\", \"Median (easy)\")\n",
"stats_quick = percentile_stats(\"error_quick\", \"Best (quick)\")\n",
"\n",
@@ -1120,24 +1153,42 @@
}
],
"source": [
- "fig = make_subplots(rows=1, cols=2, subplot_titles=[\n",
- " \"Median transit time error (R5 − TfL)\",\n",
- " \"Best transit time error (R5 − TfL)\"\n",
- "])\n",
+ "fig = make_subplots(\n",
+ " rows=1,\n",
+ " cols=2,\n",
+ " subplot_titles=[\n",
+ " \"Median transit time error (R5 − TfL)\",\n",
+ " \"Best transit time error (R5 − TfL)\",\n",
+ " ],\n",
+ ")\n",
"\n",
"# Clip for readability\n",
"easy_clipped = bank[\"error_easy\"].clip(-60, 60).to_numpy()\n",
"quick_clipped = bank[\"error_quick\"].clip(-60, 60).to_numpy()\n",
"\n",
- "fig.add_trace(go.Histogram(x=easy_clipped, nbinsx=120, name=\"Median (easy)\",\n",
- " marker_color=\"#0d9488\"), row=1, col=1)\n",
- "fig.add_trace(go.Histogram(x=quick_clipped, nbinsx=120, name=\"Best (quick)\",\n",
- " marker_color=\"#f59e0b\"), row=1, col=2)\n",
+ "fig.add_trace(\n",
+ " go.Histogram(\n",
+ " x=easy_clipped, nbinsx=120, name=\"Median (easy)\", marker_color=\"#0d9488\"\n",
+ " ),\n",
+ " row=1,\n",
+ " col=1,\n",
+ ")\n",
+ "fig.add_trace(\n",
+ " go.Histogram(\n",
+ " x=quick_clipped, nbinsx=120, name=\"Best (quick)\", marker_color=\"#f59e0b\"\n",
+ " ),\n",
+ " row=1,\n",
+ " col=2,\n",
+ ")\n",
"\n",
"fig.update_xaxes(title_text=\"Error (minutes)\", row=1, col=1)\n",
"fig.update_xaxes(title_text=\"Error (minutes)\", row=1, col=2)\n",
"fig.update_yaxes(title_text=\"Count\", row=1, col=1)\n",
- "fig.update_layout(height=400, showlegend=False, title_text=\"Bank: Error Distribution (clipped ±60 min)\")\n",
+ "fig.update_layout(\n",
+ " height=400,\n",
+ " showlegend=False,\n",
+ " title_text=\"Bank: Error Distribution (clipped ±60 min)\",\n",
+ ")\n",
"fig.show()"
]
},
@@ -2104,34 +2155,55 @@
"# Sample for scatter plot performance\n",
"sample = bank.sample(n=min(20_000, bank.shape[0]), seed=42)\n",
"\n",
- "fig = make_subplots(rows=1, cols=2, subplot_titles=[\n",
- " \"Median: R5 vs TfL (easy)\",\n",
- " \"Best: R5 vs TfL (quick)\"\n",
- "])\n",
+ "fig = make_subplots(\n",
+ " rows=1,\n",
+ " cols=2,\n",
+ " subplot_titles=[\"Median: R5 vs TfL (easy)\", \"Best: R5 vs TfL (quick)\"],\n",
+ ")\n",
"\n",
- "fig.add_trace(go.Scattergl(\n",
- " x=sample[\"public_transport_easy_minutes\"].to_numpy(),\n",
- " y=sample[\"travel_minutes\"].cast(pl.Float64).to_numpy(),\n",
- " mode=\"markers\", marker=dict(size=2, opacity=0.3, color=\"#0d9488\"),\n",
- " name=\"Median\"\n",
- "), row=1, col=1)\n",
+ "fig.add_trace(\n",
+ " go.Scattergl(\n",
+ " x=sample[\"public_transport_easy_minutes\"].to_numpy(),\n",
+ " y=sample[\"travel_minutes\"].cast(pl.Float64).to_numpy(),\n",
+ " mode=\"markers\",\n",
+ " marker=dict(size=2, opacity=0.3, color=\"#0d9488\"),\n",
+ " name=\"Median\",\n",
+ " ),\n",
+ " row=1,\n",
+ " col=1,\n",
+ ")\n",
"\n",
- "fig.add_trace(go.Scattergl(\n",
- " x=sample[\"public_transport_quick_minutes\"].to_numpy(),\n",
- " y=sample[\"best_minutes\"].cast(pl.Float64).to_numpy(),\n",
- " mode=\"markers\", marker=dict(size=2, opacity=0.3, color=\"#f59e0b\"),\n",
- " name=\"Best\"\n",
- "), row=1, col=2)\n",
+ "fig.add_trace(\n",
+ " go.Scattergl(\n",
+ " x=sample[\"public_transport_quick_minutes\"].to_numpy(),\n",
+ " y=sample[\"best_minutes\"].cast(pl.Float64).to_numpy(),\n",
+ " mode=\"markers\",\n",
+ " marker=dict(size=2, opacity=0.3, color=\"#f59e0b\"),\n",
+ " name=\"Best\",\n",
+ " ),\n",
+ " row=1,\n",
+ " col=2,\n",
+ ")\n",
"\n",
"# Perfect agreement line\n",
"for col in [1, 2]:\n",
- " fig.add_trace(go.Scatter(x=[0, 200], y=[0, 200], mode=\"lines\",\n",
- " line=dict(color=\"red\", dash=\"dash\", width=1),\n",
- " showlegend=False), row=1, col=col)\n",
+ " fig.add_trace(\n",
+ " go.Scatter(\n",
+ " x=[0, 200],\n",
+ " y=[0, 200],\n",
+ " mode=\"lines\",\n",
+ " line=dict(color=\"red\", dash=\"dash\", width=1),\n",
+ " showlegend=False,\n",
+ " ),\n",
+ " row=1,\n",
+ " col=col,\n",
+ " )\n",
" fig.update_xaxes(title_text=\"TfL API (minutes)\", row=1, col=col)\n",
" fig.update_yaxes(title_text=\"R5 (minutes)\", row=1, col=col)\n",
"\n",
- "fig.update_layout(height=500, showlegend=False, title_text=\"Bank: R5 vs TfL API (20k sample)\")\n",
+ "fig.update_layout(\n",
+ " height=500, showlegend=False, title_text=\"Bank: R5 vs TfL API (20k sample)\"\n",
+ ")\n",
"fig.show()"
]
},
@@ -403063,7 +403135,8 @@
"\n",
"fig = px.scatter_map(\n",
" map_sample.to_pandas(),\n",
- " lat=\"lat\", lon=\"long\",\n",
+ " lat=\"lat\",\n",
+ " lon=\"long\",\n",
" color=\"error_easy\",\n",
" color_continuous_scale=\"RdBu_r\", # red=positive (R5 slower), blue=negative (R5 faster)\n",
" range_color=[-30, 30],\n",
@@ -403071,8 +403144,14 @@
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
" opacity=0.5,\n",
" title=\"Bank — Median transit error (R5 − TfL easy), minutes\",\n",
- " hover_data={\"pcds\": True, \"travel_minutes\": True, \"public_transport_easy_minutes\": True,\n",
- " \"error_easy\": \":.0f\", \"lat\": False, \"long\": False},\n",
+ " hover_data={\n",
+ " \"pcds\": True,\n",
+ " \"travel_minutes\": True,\n",
+ " \"public_transport_easy_minutes\": True,\n",
+ " \"error_easy\": \":.0f\",\n",
+ " \"lat\": False,\n",
+ " \"long\": False,\n",
+ " },\n",
" height=700,\n",
")\n",
"fig.update_layout(map_style=\"carto-positron\")\n",
@@ -803994,7 +804073,8 @@
"source": [
"fig = px.scatter_map(\n",
" map_sample.to_pandas(),\n",
- " lat=\"lat\", lon=\"long\",\n",
+ " lat=\"lat\",\n",
+ " lon=\"long\",\n",
" color=\"error_quick\",\n",
" color_continuous_scale=\"RdBu_r\",\n",
" range_color=[-30, 30],\n",
@@ -804002,8 +804082,14 @@
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
" opacity=0.5,\n",
" title=\"Bank — Best transit error (R5 − TfL quick), minutes\",\n",
- " hover_data={\"pcds\": True, \"best_minutes\": True, \"public_transport_quick_minutes\": True,\n",
- " \"error_quick\": \":.0f\", \"lat\": False, \"long\": False},\n",
+ " hover_data={\n",
+ " \"pcds\": True,\n",
+ " \"best_minutes\": True,\n",
+ " \"public_transport_quick_minutes\": True,\n",
+ " \"error_quick\": \":.0f\",\n",
+ " \"lat\": False,\n",
+ " \"long\": False,\n",
+ " },\n",
" height=700,\n",
")\n",
"fig.update_layout(map_style=\"carto-positron\")\n",
@@ -1204925,7 +1205011,8 @@
"source": [
"fig = px.scatter_map(\n",
" map_sample.to_pandas(),\n",
- " lat=\"lat\", lon=\"long\",\n",
+ " lat=\"lat\",\n",
+ " lon=\"long\",\n",
" color=\"abs_error_easy\",\n",
" color_continuous_scale=\"YlOrRd\",\n",
" range_color=[0, 30],\n",
@@ -1204933,8 +1205020,14 @@
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
" opacity=0.5,\n",
" title=\"Bank — Absolute median transit error |R5 − TfL easy|, minutes\",\n",
- " hover_data={\"pcds\": True, \"travel_minutes\": True, \"public_transport_easy_minutes\": True,\n",
- " \"abs_error_easy\": \":.0f\", \"lat\": False, \"long\": False},\n",
+ " hover_data={\n",
+ " \"pcds\": True,\n",
+ " \"travel_minutes\": True,\n",
+ " \"public_transport_easy_minutes\": True,\n",
+ " \"abs_error_easy\": \":.0f\",\n",
+ " \"lat\": False,\n",
+ " \"long\": False,\n",
+ " },\n",
" height=700,\n",
")\n",
"fig.update_layout(map_style=\"carto-positron\")\n",
@@ -1204998,9 +1205091,15 @@
],
"source": [
"bank.sort(\"abs_error_easy\", descending=True).select(\n",
- " \"pcds\", \"lat\", \"long\",\n",
- " \"travel_minutes\", \"public_transport_easy_minutes\", \"error_easy\",\n",
- " \"best_minutes\", \"public_transport_quick_minutes\", \"error_quick\",\n",
+ " \"pcds\",\n",
+ " \"lat\",\n",
+ " \"long\",\n",
+ " \"travel_minutes\",\n",
+ " \"public_transport_easy_minutes\",\n",
+ " \"error_easy\",\n",
+ " \"best_minutes\",\n",
+ " \"public_transport_quick_minutes\",\n",
+ " \"error_quick\",\n",
").head(30)"
]
},
@@ -1205945,45 +1206044,75 @@
"\n",
"dist_df = bank.with_columns(\n",
" # Rough km distance using Haversine approximation\n",
- " ((((pl.col(\"lat\") - BANK_LAT) * 111.32) ** 2 +\n",
- " ((pl.col(\"long\") - BANK_LON) * 111.32 * np.cos(np.radians(BANK_LAT))) ** 2) ** 0.5\n",
+ " (\n",
+ " (\n",
+ " ((pl.col(\"lat\") - BANK_LAT) * 111.32) ** 2\n",
+ " + ((pl.col(\"long\") - BANK_LON) * 111.32 * np.cos(np.radians(BANK_LAT))) ** 2\n",
+ " )\n",
+ " ** 0.5\n",
" ).alias(\"dist_km\")\n",
")\n",
"\n",
"# Bin by 5km\n",
"binned = (\n",
- " dist_df\n",
- " .with_columns((pl.col(\"dist_km\") / 5).floor() * 5)\n",
+ " dist_df.with_columns((pl.col(\"dist_km\") / 5).floor() * 5)\n",
" .group_by(\"dist_km\")\n",
- " .agg([\n",
- " pl.col(\"error_easy\").median().alias(\"median_error_easy\"),\n",
- " pl.col(\"error_quick\").median().alias(\"median_error_quick\"),\n",
- " pl.col(\"abs_error_easy\").median().alias(\"median_abs_error_easy\"),\n",
- " pl.len().alias(\"count\"),\n",
- " ])\n",
+ " .agg(\n",
+ " [\n",
+ " pl.col(\"error_easy\").median().alias(\"median_error_easy\"),\n",
+ " pl.col(\"error_quick\").median().alias(\"median_error_quick\"),\n",
+ " pl.col(\"abs_error_easy\").median().alias(\"median_abs_error_easy\"),\n",
+ " pl.len().alias(\"count\"),\n",
+ " ]\n",
+ " )\n",
" .sort(\"dist_km\")\n",
" .filter(pl.col(\"count\") > 50)\n",
")\n",
"\n",
- "fig = make_subplots(rows=1, cols=2, subplot_titles=[\n",
- " \"Median signed error by distance\",\n",
- " \"Median absolute error by distance\"\n",
- "])\n",
+ "fig = make_subplots(\n",
+ " rows=1,\n",
+ " cols=2,\n",
+ " subplot_titles=[\n",
+ " \"Median signed error by distance\",\n",
+ " \"Median absolute error by distance\",\n",
+ " ],\n",
+ ")\n",
"\n",
- "fig.add_trace(go.Scatter(\n",
- " x=binned[\"dist_km\"].to_numpy(), y=binned[\"median_error_easy\"].to_numpy(),\n",
- " mode=\"lines+markers\", name=\"Easy\", line=dict(color=\"#0d9488\")\n",
- "), row=1, col=1)\n",
- "fig.add_trace(go.Scatter(\n",
- " x=binned[\"dist_km\"].to_numpy(), y=binned[\"median_error_quick\"].to_numpy(),\n",
- " mode=\"lines+markers\", name=\"Quick\", line=dict(color=\"#f59e0b\")\n",
- "), row=1, col=1)\n",
+ "fig.add_trace(\n",
+ " go.Scatter(\n",
+ " x=binned[\"dist_km\"].to_numpy(),\n",
+ " y=binned[\"median_error_easy\"].to_numpy(),\n",
+ " mode=\"lines+markers\",\n",
+ " name=\"Easy\",\n",
+ " line=dict(color=\"#0d9488\"),\n",
+ " ),\n",
+ " row=1,\n",
+ " col=1,\n",
+ ")\n",
+ "fig.add_trace(\n",
+ " go.Scatter(\n",
+ " x=binned[\"dist_km\"].to_numpy(),\n",
+ " y=binned[\"median_error_quick\"].to_numpy(),\n",
+ " mode=\"lines+markers\",\n",
+ " name=\"Quick\",\n",
+ " line=dict(color=\"#f59e0b\"),\n",
+ " ),\n",
+ " row=1,\n",
+ " col=1,\n",
+ ")\n",
"\n",
- "fig.add_trace(go.Scatter(\n",
- " x=binned[\"dist_km\"].to_numpy(), y=binned[\"median_abs_error_easy\"].to_numpy(),\n",
- " mode=\"lines+markers\", name=\"|Easy|\", line=dict(color=\"#0d9488\"),\n",
- " showlegend=False\n",
- "), row=1, col=2)\n",
+ "fig.add_trace(\n",
+ " go.Scatter(\n",
+ " x=binned[\"dist_km\"].to_numpy(),\n",
+ " y=binned[\"median_abs_error_easy\"].to_numpy(),\n",
+ " mode=\"lines+markers\",\n",
+ " name=\"|Easy|\",\n",
+ " line=dict(color=\"#0d9488\"),\n",
+ " showlegend=False,\n",
+ " ),\n",
+ " row=1,\n",
+ " col=2,\n",
+ ")\n",
"\n",
"for col in [1, 2]:\n",
" fig.update_xaxes(title_text=\"Distance from Bank (km)\", row=1, col=col)\n",
diff --git a/docker-compose.yml b/docker-compose.yml
index c8126ae..0b0f525 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -146,6 +146,12 @@ services:
# networks:
# - dev-network
# restart: unless-stopped
+ # healthcheck:
+ # test: ["CMD", "curl", "-f", "http://localhost:8191/health"]
+ # interval: 30s
+ # timeout: 5s
+ # retries: 3
+ # start_period: 30s
# finder:
# build:
@@ -161,8 +167,14 @@ services:
# gluetun:
# condition: service_healthy
# flaresolverr:
- # condition: service_started
+ # condition: service_healthy
# restart: unless-stopped
+ # healthcheck:
+ # test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:1234/health')"]
+ # interval: 30s
+ # timeout: 5s
+ # retries: 3
+ # start_period: 60s
volumes:
diff --git a/finder/Dockerfile b/finder/Dockerfile
index c975550..00c0344 100644
--- a/finder/Dockerfile
+++ b/finder/Dockerfile
@@ -5,9 +5,14 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml ./
RUN uv pip install --system -r pyproject.toml
-RUN playwright install --with-deps chromium
+RUN playwright install-deps firefox
+RUN camoufox fetch \
+ && python -c "from camoufox.pkgman import camoufox_path; p = camoufox_path(download_if_missing=False); print('Camoufox verified at', p)"
COPY *.py ./
COPY property-data/arcgis_data.parquet /data/arcgis_data.parquet
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:1234/health')"
+
CMD ["python3", "main.py"]
diff --git a/finder/constants.py b/finder/constants.py
index b42a961..2985486 100644
--- a/finder/constants.py
+++ b/finder/constants.py
@@ -4,8 +4,8 @@ from pathlib import Path
ARCGIS_PATH = os.environ.get("ARCGIS_PATH", "/data/arcgis_data.parquet")
DATA_DIR = Path("/app/data")
PAGE_SIZE = 24
-DELAY_BETWEEN_PAGES = 1.0
-DELAY_BETWEEN_OUTCODES = 2.0
+DELAY_BETWEEN_PAGES = 0.5
+DELAY_BETWEEN_OUTCODES = 1.0
MAX_RETRIES = 3
RETRY_BASE_DELAY = 2.0
GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index
@@ -16,9 +16,29 @@ SCHEDULE_HOUR = int(os.environ.get("SCHEDULE_HOUR", "3"))
# Whether to run a scrape immediately on startup
RUN_ON_STARTUP = os.environ.get("RUN_ON_STARTUP", "").lower() in ("1", "true", "yes")
# Enable/disable individual sources
-SCRAPE_RIGHTMOVE = os.environ.get("SCRAPE_RIGHTMOVE", "true").lower() in ("1", "true", "yes")
-SCRAPE_HOMECOUK = os.environ.get("SCRAPE_HOMECOUK", "true").lower() in ("1", "true", "yes")
-SCRAPE_OPENRENT = os.environ.get("SCRAPE_OPENRENT", "true").lower() in ("1", "true", "yes")
+SCRAPE_RIGHTMOVE = os.environ.get("SCRAPE_RIGHTMOVE", "true").lower() in (
+ "1",
+ "true",
+ "yes",
+)
+SCRAPE_HOMECOUK = os.environ.get("SCRAPE_HOMECOUK", "true").lower() in (
+ "1",
+ "true",
+ "yes",
+)
+SCRAPE_OPENRENT = os.environ.get("SCRAPE_OPENRENT", "true").lower() in (
+ "1",
+ "true",
+ "yes",
+)
+SCRAPE_ZOOPLA = os.environ.get("SCRAPE_ZOOPLA", "true").lower() in (
+ "1",
+ "true",
+ "yes",
+)
+
+# URL to trigger server data reload after scrape (e.g. http://server:8001/api/reload)
+RELOAD_URL = os.environ.get("RELOAD_URL", "")
TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
@@ -32,6 +52,9 @@ HOMECOUK_PER_PAGE = 30 # max supported by the API
# OpenRent
OPENRENT_BASE = "https://www.openrent.co.uk"
+# Zoopla
+ZOOPLA_BASE = "https://www.zoopla.co.uk"
+
PROPERTY_TYPE_MAP = {
"Detached": "Detached",
"Semi-Detached": "Semi-Detached",
@@ -44,6 +67,7 @@ PROPERTY_TYPE_MAP = {
"Apartment": "Flats/Maisonettes",
"Penthouse": "Flats/Maisonettes",
"Ground Flat": "Flats/Maisonettes",
+ "Duplex": "Flats/Maisonettes",
"Detached Bungalow": "Detached",
"Semi-Detached Bungalow": "Semi-Detached",
"Town House": "Terraced",
@@ -52,9 +76,15 @@ PROPERTY_TYPE_MAP = {
"Bungalow": "Other",
"Cottage": "Other",
"Park Home": "Other",
+ "Mobile Home": "Other",
+ "Caravan": "Other",
+ "Lodge": "Other",
"Land": "Other",
"Farm / Barn": "Other",
+ "Farm House": "Other",
"House": "Detached",
+ "House of Multiple Occupation": "Flats/Maisonettes",
+ "House Share": "Other",
"Not Specified": "Other",
"Chalet": "Other",
"Barn Conversion": "Other",
@@ -62,9 +92,20 @@ PROPERTY_TYPE_MAP = {
"Character Property": "Other",
"Cluster House": "Other",
"Retirement Property": "Flats/Maisonettes",
+ "Parking": "Other",
"Plot": "Other",
"Garages": "Other",
"Mews": "Terraced",
+ "Property": "Other",
+ # Lowercase variants (from home.co.uk / Rightmove APIs)
+ "house": "Detached",
+ "bungalow": "Other",
+ "townhouse": "Terraced",
+ "land": "Other",
+ "other": "Other",
+ "not-specified": "Other",
+ "retirement-property": "Flats/Maisonettes",
+ "equestrian-facility": "Other",
}
CHANNELS = [
diff --git a/finder/homecouk.py b/finder/homecouk.py
index f6005fd..fc18fdf 100644
--- a/finder/homecouk.py
+++ b/finder/homecouk.py
@@ -86,7 +86,8 @@ def solve_cloudflare() -> tuple[dict[str, str], str] | None:
log.info(
"Cloudflare solved — got %d cookies, UA: %s",
- len(cookies), user_agent[:60],
+ len(cookies),
+ user_agent[:60],
)
flaresolverr_attempts_total.labels(result="success").inc()
return cookies, user_agent
@@ -129,11 +130,13 @@ def make_client(cookies: dict[str, str], user_agent: str) -> Session:
Uses Chrome TLS impersonation so cf_clearance cookies (which are bound
to Chrome's JA3 fingerprint from FlareSolverr) remain valid."""
session = Session(impersonate="chrome")
- session.headers.update({
- "User-Agent": user_agent,
- "Accept": "application/json, text/plain, */*",
- "x-requested-with": "XMLHttpRequest",
- })
+ session.headers.update(
+ {
+ "User-Agent": user_agent,
+ "Accept": "application/json, text/plain, */*",
+ "x-requested-with": "XMLHttpRequest",
+ }
+ )
# Laravel CSRF: the XSRF-TOKEN cookie value must also be sent as the
# X-XSRF-TOKEN request header (URL-decoded). Without this header, the
# server rejects every request with 419/403.
@@ -165,7 +168,11 @@ def fetch_page(
return resp.json()
except json.JSONDecodeError:
homecouk_errors_total.labels(type="json_decode").inc()
- log.error("Non-JSON response from %s (got %s)", url, resp.headers.get("content-type", "?"))
+ log.error(
+ "Non-JSON response from %s (got %s)",
+ url,
+ resp.headers.get("content-type", "?"),
+ )
return None
if resp.status_code == 403:
raise CookiesExpiredError("HTTP 403 — cookies likely expired")
@@ -173,7 +180,11 @@ def fetch_page(
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
log.warning(
"HTTP %d from %s, retry %d/%d in %.1fs",
- resp.status_code, url, attempt + 1, max_retries, delay,
+ resp.status_code,
+ url,
+ attempt + 1,
+ max_retries,
+ delay,
)
time.sleep(delay)
continue
@@ -186,7 +197,11 @@ def fetch_page(
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
log.warning(
"%s from %s, retry %d/%d in %.1fs",
- type(e).__name__, url, attempt + 1, max_retries, delay,
+ type(e).__name__,
+ url,
+ attempt + 1,
+ max_retries,
+ delay,
)
time.sleep(delay)
homecouk_errors_total.labels(type="retry_exhausted").inc()
@@ -218,7 +233,12 @@ def map_property_type(raw_type: str | None) -> str:
# Home.co.uk uses types like "House", "Flat", "Apartment", "Detached", etc.
# Try common patterns
lower = raw_type.lower()
- if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower:
+ if (
+ "flat" in lower
+ or "apartment" in lower
+ or "maisonette" in lower
+ or "studio" in lower
+ ):
return "Flats/Maisonettes"
if "detached" in lower and "semi" not in lower:
return "Detached"
@@ -231,7 +251,9 @@ def map_property_type(raw_type: str | None) -> str:
def transform_property(
- prop: dict, channel: str, pc_index: PostcodeSpatialIndex,
+ prop: dict,
+ channel: str,
+ pc_index: PostcodeSpatialIndex,
) -> dict | None:
"""Transform a raw home.co.uk property dict into our output schema."""
lat = prop.get("latitude")
diff --git a/finder/http_client.py b/finder/http_client.py
index ecc993f..64be33b 100644
--- a/finder/http_client.py
+++ b/finder/http_client.py
@@ -11,7 +11,9 @@ from metrics import http_errors_total, http_requests_total, ip_rotations_total
log = logging.getLogger("rightmove")
-_ua = UserAgent(browsers=["Chrome", "Edge"], os=["Windows", "Mac OS X"], min_version=120.0)
+_ua = UserAgent(
+ browsers=["Chrome", "Edge"], os=["Windows", "Mac OS X"], min_version=120.0
+)
def _endpoint_label(url: str) -> str:
@@ -27,6 +29,7 @@ def _status_label(code: int) -> str:
return "5xx"
return str(code)
+
# Gluetun control API — runs on port 8000 inside the gluetun container.
# Since finder uses network_mode: service:gluetun, localhost IS gluetun.
GLUETUN_API = "http://127.0.0.1:8000"
@@ -42,17 +45,25 @@ def rotate_ip() -> bool:
# Get current IP
with httpx.Client(timeout=10) as ctl:
old_ip_resp = ctl.get(f"{GLUETUN_API}/v1/publicip/ip")
- old_ip = old_ip_resp.json().get("public_ip", "unknown") if old_ip_resp.status_code == 200 else "unknown"
+ old_ip = (
+ old_ip_resp.json().get("public_ip", "unknown")
+ if old_ip_resp.status_code == 200
+ else "unknown"
+ )
log.info("Current IP: %s", old_ip)
# Trigger server change — PUT with empty JSON body picks a random server
- resp = ctl.put(f"{GLUETUN_API}/v1/vpn/status", json={"status": "stopped"})
+ resp = ctl.put(
+ f"{GLUETUN_API}/v1/vpn/status", json={"status": "stopped"}
+ )
if resp.status_code != 200:
log.error("Failed to stop VPN: %d %s", resp.status_code, resp.text)
return False
time.sleep(2)
- resp = ctl.put(f"{GLUETUN_API}/v1/vpn/status", json={"status": "running"})
+ resp = ctl.put(
+ f"{GLUETUN_API}/v1/vpn/status", json={"status": "running"}
+ )
if resp.status_code != 200:
log.error("Failed to start VPN: %d %s", resp.status_code, resp.text)
return False
@@ -99,7 +110,9 @@ def fetch_with_retry(
for attempt in range(MAX_RETRIES):
try:
resp = client.get(url, params=params)
- http_requests_total.labels(status=_status_label(resp.status_code), endpoint=endpoint).inc()
+ http_requests_total.labels(
+ status=_status_label(resp.status_code), endpoint=endpoint
+ ).inc()
if resp.status_code == 200:
return resp.json()
if resp.status_code == 403 and on_403:
@@ -111,15 +124,34 @@ def fetch_with_retry(
return None
if resp.status_code in (429, 500, 502, 503, 504):
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
- log.warning("HTTP %d from %s, retry %d/%d in %.1fs", resp.status_code, url, attempt + 1, MAX_RETRIES, delay)
+ log.warning(
+ "HTTP %d from %s, retry %d/%d in %.1fs",
+ resp.status_code,
+ url,
+ attempt + 1,
+ MAX_RETRIES,
+ delay,
+ )
time.sleep(delay)
continue
log.error("HTTP %d from %s (non-retryable)", resp.status_code, url)
return None
- except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
+ except (
+ httpx.ConnectError,
+ httpx.ReadTimeout,
+ httpx.WriteTimeout,
+ httpx.PoolTimeout,
+ ) as e:
http_errors_total.labels(type=type(e).__name__).inc()
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
- log.warning("%s from %s, retry %d/%d in %.1fs", type(e).__name__, url, attempt + 1, MAX_RETRIES, delay)
+ log.warning(
+ "%s from %s, retry %d/%d in %.1fs",
+ type(e).__name__,
+ url,
+ attempt + 1,
+ MAX_RETRIES,
+ delay,
+ )
time.sleep(delay)
http_errors_total.labels(type="retry_exhausted").inc()
log.error("All %d retries exhausted for %s", MAX_RETRIES, url)
diff --git a/finder/main.py b/finder/main.py
index 666033b..b68f824 100644
--- a/finder/main.py
+++ b/finder/main.py
@@ -7,7 +7,15 @@ from pathlib import Path
from flask import Flask, Response, jsonify, send_from_directory
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
-from constants import DATA_DIR, RUN_ON_STARTUP, SCHEDULE_HOUR, SCRAPE_HOMECOUK, SCRAPE_OPENRENT, SCRAPE_RIGHTMOVE
+from constants import (
+ DATA_DIR,
+ RUN_ON_STARTUP,
+ SCHEDULE_HOUR,
+ SCRAPE_HOMECOUK,
+ SCRAPE_OPENRENT,
+ SCRAPE_RIGHTMOVE,
+ SCRAPE_ZOOPLA,
+)
from homecouk import load_cookies as load_homecouk_cookies
from openrent import load_cookies as load_openrent_cookies
from rightmove import outcode_cache
@@ -41,6 +49,16 @@ log.setLevel(logging.DEBUG)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
+
+# Suppress noisy /metrics and /health request logs from werkzeug
+class _NoiseFilter(logging.Filter):
+ def filter(self, record):
+ msg = record.getMessage()
+ return "GET /metrics" not in msg and "GET /health" not in msg
+
+
+logging.getLogger("werkzeug").addFilter(_NoiseFilter())
+
# ---------------------------------------------------------------------------
# Startup: load data
# ---------------------------------------------------------------------------
@@ -48,9 +66,15 @@ logging.getLogger("httpcore").setLevel(logging.WARNING)
log.info("Loading arcgis data...")
OUTCODES = load_outcodes()
PC_INDEX = build_postcode_index()
-PC_COORDS = build_postcode_coords() if SCRAPE_OPENRENT else None
-log.info("Ready — %d outcodes, postcode index built (rightmove=%s, homecouk=%s, openrent=%s)",
- len(OUTCODES), SCRAPE_RIGHTMOVE, SCRAPE_HOMECOUK, SCRAPE_OPENRENT)
+PC_COORDS = build_postcode_coords() if (SCRAPE_OPENRENT or SCRAPE_ZOOPLA) else None
+log.info(
+ "Ready — %d outcodes, postcode index built (rightmove=%s, homecouk=%s, openrent=%s, zoopla=%s)",
+ len(OUTCODES),
+ SCRAPE_RIGHTMOVE,
+ SCRAPE_HOMECOUK,
+ SCRAPE_OPENRENT,
+ SCRAPE_ZOOPLA,
+)
# ---------------------------------------------------------------------------
# Scheduler
@@ -63,7 +87,9 @@ def _start_scrape() -> bool:
if status.state == "running":
return False
status.state = "running"
- thread = threading.Thread(target=run_scrape, args=(OUTCODES, PC_INDEX, PC_COORDS), daemon=True)
+ thread = threading.Thread(
+ target=run_scrape, args=(OUTCODES, PC_INDEX, PC_COORDS), daemon=True
+ )
thread.start()
return True
@@ -82,7 +108,9 @@ def _scheduler_loop() -> None:
log.info("Scheduler active — will run daily at %02d:00 UTC", SCHEDULE_HOUR)
while True:
wait = _seconds_until(SCHEDULE_HOUR)
- log.info("Next scheduled scrape in %.0f seconds (%.1f hours)", wait, wait / 3600)
+ log.info(
+ "Next scheduled scrape in %.0f seconds (%.1f hours)", wait, wait / 3600
+ )
time.sleep(wait)
log.info("Scheduled scrape triggered")
if not _start_scrape():
@@ -105,6 +133,11 @@ if SCHEDULE_HOUR >= 0:
app = Flask(__name__)
+@app.route("/health")
+def health():
+ return "ok", 200
+
+
@app.route("/run", methods=["POST"])
def trigger_run():
if _start_scrape():
@@ -131,6 +164,7 @@ def get_status():
"rightmove": status.rm_properties,
"homecouk": status.hk_properties,
"openrent": status.or_properties,
+ "zoopla": status.zp_properties,
},
"errors": status.errors[-20:], # last 20 errors
"elapsed_seconds": round(elapsed, 1),
@@ -144,15 +178,19 @@ def get_status():
def get_debug():
hk_cookies = load_homecouk_cookies() if SCRAPE_HOMECOUK else None
or_cookies = load_openrent_cookies() if SCRAPE_OPENRENT else None
- return jsonify({
- "outcode_cache_size": len(outcode_cache),
- "outcode_cache_sample": dict(list(outcode_cache.items())[:20]),
- "scrape_rightmove": SCRAPE_RIGHTMOVE,
- "scrape_homecouk": SCRAPE_HOMECOUK,
- "scrape_openrent": SCRAPE_OPENRENT,
- "homecouk_cookies_available": hk_cookies is not None,
- "openrent_cookies_available": or_cookies is not None,
- })
+ return jsonify(
+ {
+ "outcode_cache_size": len(outcode_cache),
+ "outcode_cache_sample": dict(list(outcode_cache.items())[:20]),
+ "scrape_rightmove": SCRAPE_RIGHTMOVE,
+ "scrape_homecouk": SCRAPE_HOMECOUK,
+ "scrape_openrent": SCRAPE_OPENRENT,
+ "scrape_zoopla": SCRAPE_ZOOPLA,
+ "homecouk_cookies_available": hk_cookies is not None,
+ "openrent_cookies_available": or_cookies is not None,
+ "zoopla_note": "browser-based (Camoufox), no cookies needed",
+ }
+ )
@app.route("/metrics")
diff --git a/finder/metrics.py b/finder/metrics.py
index 134cc7f..df8ae26 100644
--- a/finder/metrics.py
+++ b/finder/metrics.py
@@ -109,6 +109,28 @@ openrent_properties_scraped = Counter(
["channel"],
)
+# ---------------------------------------------------------------------------
+# Counters — Zoopla
+# ---------------------------------------------------------------------------
+
+zoopla_pages_scraped = Counter(
+ "zoopla_pages_scraped",
+ "Search result pages scraped from Zoopla",
+ ["channel"],
+)
+
+zoopla_errors_total = Counter(
+ "zoopla_errors_total",
+ "Zoopla scraping errors",
+ ["type"],
+)
+
+zoopla_properties_scraped = Counter(
+ "zoopla_properties_scraped",
+ "Properties scraped from Zoopla (before dedup)",
+ ["channel"],
+)
+
# ---------------------------------------------------------------------------
# Counters — FlareSolverr / cookie management
# ---------------------------------------------------------------------------
@@ -138,3 +160,8 @@ openrent_enabled = Gauge(
"openrent_enabled",
"Whether OpenRent scraping is currently active (1=yes, 0=no)",
)
+
+zoopla_enabled = Gauge(
+ "zoopla_enabled",
+ "Whether Zoopla scraping is currently active (1=yes, 0=no)",
+)
diff --git a/finder/openrent.py b/finder/openrent.py
index d66d0f6..c96dd44 100644
--- a/finder/openrent.py
+++ b/finder/openrent.py
@@ -79,7 +79,8 @@ def solve_waf() -> tuple[dict[str, str], str] | None:
if "AwsWafIntegration" in content:
log.info("Got WAF challenge page, waiting for resolution...")
page.wait_for_selector(
- "a.pli, .pli, .search-property-card", timeout=30000,
+ "a.pli, .pli, .search-property-card",
+ timeout=30000,
)
raw_cookies = context.cookies()
@@ -94,7 +95,8 @@ def solve_waf() -> tuple[dict[str, str], str] | None:
log.info(
"AWS WAF solved — got %d cookies, UA: %s",
- len(cookies), user_agent[:60],
+ len(cookies),
+ user_agent[:60],
)
flaresolverr_attempts_total.labels(result="success").inc()
return cookies, user_agent
@@ -130,11 +132,13 @@ def make_client(cookies: dict[str, str], user_agent: str) -> Session:
"""Create a curl_cffi Session configured for OpenRent.
Uses Chrome TLS impersonation so AWS WAF cookies remain valid."""
session = Session(impersonate="chrome")
- session.headers.update({
- "User-Agent": user_agent,
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "Accept-Language": "en-GB,en;q=0.9",
- })
+ session.headers.update(
+ {
+ "User-Agent": user_agent,
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "en-GB,en;q=0.9",
+ }
+ )
for name, value in cookies.items():
session.cookies.set(name, value, domain="openrent.co.uk")
return session
@@ -152,7 +156,9 @@ def _status_label(code: int) -> str:
def fetch_page(
- client: Session, url: str, max_retries: int = 3,
+ client: Session,
+ url: str,
+ max_retries: int = 3,
) -> str | None:
"""GET HTML with retries on 429/5xx. Returns None on permanent failure.
WAF challenge (202 or 403 with challenge JS) raises WafChallengeError."""
@@ -165,17 +171,25 @@ def fetch_page(
html = resp.text
# Detect WAF challenge page masquerading as 200
if "AwsWafIntegration" in html and "challenge.js" in html:
- raise WafChallengeError("Got AWS WAF challenge page — cookies expired")
+ raise WafChallengeError(
+ "Got AWS WAF challenge page — cookies expired"
+ )
return html
if resp.status_code in (202, 403):
- raise WafChallengeError(f"HTTP {resp.status_code} — cookies likely expired")
+ raise WafChallengeError(
+ f"HTTP {resp.status_code} — cookies likely expired"
+ )
if resp.status_code in (429, 500, 502, 503, 504):
- delay = RETRY_BASE_DELAY * (2 ** attempt)
+ delay = RETRY_BASE_DELAY * (2**attempt)
log.warning(
"HTTP %d from %s, retry %d/%d in %.1fs",
- resp.status_code, url, attempt + 1, max_retries, delay,
+ resp.status_code,
+ url,
+ attempt + 1,
+ max_retries,
+ delay,
)
time.sleep(delay)
continue
@@ -187,10 +201,14 @@ def fetch_page(
raise
except RequestsError as e:
openrent_errors_total.labels(type=type(e).__name__).inc()
- delay = RETRY_BASE_DELAY * (2 ** attempt)
+ delay = RETRY_BASE_DELAY * (2**attempt)
log.warning(
"%s from %s, retry %d/%d in %.1fs",
- type(e).__name__, url, attempt + 1, max_retries, delay,
+ type(e).__name__,
+ url,
+ attempt + 1,
+ max_retries,
+ delay,
)
time.sleep(delay)
@@ -247,7 +265,9 @@ def _extract_bedrooms_from_title(title: str) -> int | None:
return None
-def _extract_beds_baths_from_features(feature_items: list) -> tuple[int | None, int | None]:
+def _extract_beds_baths_from_features(
+ feature_items: list,
+) -> tuple[int | None, int | None]:
"""Extract bedrooms and bathrooms from feature list items.
OpenRent search cards have
with items like:
@@ -442,11 +462,7 @@ def parse_search_results(html: str) -> list[dict]:
# --- Coordinates from data attributes (may not be present on cards) ---
for el in [card] + card.select("[data-lat], [data-latitude]"):
lat = el.get("data-lat") or el.get("data-latitude")
- lng = (
- el.get("data-lng")
- or el.get("data-longitude")
- or el.get("data-lon")
- )
+ lng = el.get("data-lng") or el.get("data-longitude") or el.get("data-lon")
if lat and lng:
try:
prop["lat"] = float(lat)
@@ -543,9 +559,7 @@ def parse_property_detail(html: str) -> dict:
break
# --- Description for floor area ---
- desc_el = soup.select_one(
- ".description, [class*='description'], #description"
- )
+ desc_el = soup.select_one(".description, [class*='description'], #description")
if desc_el:
details["description"] = desc_el.get_text(strip=True)
@@ -567,7 +581,12 @@ def map_property_type(raw_type: str | None) -> str:
lower = raw_type.lower()
if "room" in lower or "shared" in lower:
return "Other"
- if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower:
+ if (
+ "flat" in lower
+ or "apartment" in lower
+ or "maisonette" in lower
+ or "studio" in lower
+ ):
return "Flats/Maisonettes"
if "detached" in lower and "semi" not in lower:
return "Detached"
@@ -647,7 +666,8 @@ def transform_property(
elif search_data.get("outcode"):
# No spatial index — try outcode lookup as fallback
outcode_pcs = _resolve_outcode_postcodes(
- search_data["outcode"], pc_coords,
+ search_data["outcode"],
+ pc_coords,
)
if outcode_pcs:
postcode = outcode_pcs[0]
@@ -708,7 +728,8 @@ def transform_property(
prop_id = search_data.get("id", "")
listing_url = search_data.get(
- "url", f"{OPENRENT_BASE}/{prop_id}" if prop_id else "",
+ "url",
+ f"{OPENRENT_BASE}/{prop_id}" if prop_id else "",
)
description = detail.get("description") or search_data.get("description", "")
@@ -767,7 +788,24 @@ def search_outcode(
for search_data in search_results:
detail_data = None
- if fetch_details and search_data.get("url"):
+ # Skip detail page if we already have coordinates or a resolvable postcode
+ has_coords = (
+ search_data.get("lat") is not None
+ and search_data.get("lng") is not None
+ )
+ has_resolvable_pc = (
+ search_data.get("postcode")
+ and pc_coords
+ and search_data["postcode"] in pc_coords
+ )
+ needs_detail = (
+ fetch_details
+ and search_data.get("url")
+ and not has_coords
+ and not has_resolvable_pc
+ )
+
+ if needs_detail:
detail_html = fetch_page(client, search_data["url"])
if detail_html:
detail_data = parse_property_detail(detail_html)
@@ -775,7 +813,10 @@ def search_outcode(
time.sleep(DELAY_BETWEEN_PAGES * 0.5)
transformed = transform_property(
- search_data, detail_data, pc_index, pc_coords,
+ search_data,
+ detail_data,
+ pc_index,
+ pc_coords,
)
if transformed:
properties.append(transformed)
diff --git a/finder/pyproject.toml b/finder/pyproject.toml
index ad1dcfa..05379b6 100644
--- a/finder/pyproject.toml
+++ b/finder/pyproject.toml
@@ -11,4 +11,6 @@ dependencies = [
"prometheus-client",
"beautifulsoup4",
"playwright>=1.58.0",
+ "playwright-stealth>=2.0.2",
+ "camoufox>=0.4.11",
]
diff --git a/finder/rightmove.py b/finder/rightmove.py
index c8471cd..c00f5cb 100644
--- a/finder/rightmove.py
+++ b/finder/rightmove.py
@@ -24,7 +24,9 @@ def resolve_outcode_id(client: httpx.Client, outcode: str) -> str | None:
if outcode in outcode_cache:
return outcode_cache[outcode]
- data = fetch_with_retry(client, TYPEAHEAD_URL, {"query": outcode, "limit": "10", "exclude": "STREET"})
+ data = fetch_with_retry(
+ client, TYPEAHEAD_URL, {"query": outcode, "limit": "10", "exclude": "STREET"}
+ )
if not data:
return None
@@ -61,7 +63,12 @@ def search_outcode(
data = fetch_with_retry(client, SEARCH_URL, params)
if not data:
- log.warning("Failed to fetch index %d for %s/%s", index, outcode, channel_cfg["channel"])
+ log.warning(
+ "Failed to fetch index %d for %s/%s",
+ index,
+ outcode,
+ channel_cfg["channel"],
+ )
break
raw_props = data.get("properties", [])
diff --git a/finder/scraper.py b/finder/scraper.py
index 7031c66..88c3dd2 100644
--- a/finder/scraper.py
+++ b/finder/scraper.py
@@ -6,7 +6,20 @@ from dataclasses import dataclass, field
import polars as pl
-from constants import ARCGIS_PATH, CHANNELS, DATA_DIR, DELAY_BETWEEN_OUTCODES, SCRAPE_HOMECOUK, SCRAPE_OPENRENT, SCRAPE_RIGHTMOVE, SEED
+import httpx
+
+from constants import (
+ ARCGIS_PATH,
+ CHANNELS,
+ DATA_DIR,
+ DELAY_BETWEEN_OUTCODES,
+ RELOAD_URL,
+ SCRAPE_HOMECOUK,
+ SCRAPE_OPENRENT,
+ SCRAPE_RIGHTMOVE,
+ SCRAPE_ZOOPLA,
+ SEED,
+)
from homecouk import CookiesExpiredError
from homecouk import load_cookies as load_homecouk_cookies
from homecouk import make_client as make_homecouk_client
@@ -23,12 +36,16 @@ from metrics import (
scrape_outcodes_total,
scrape_properties_total,
scrape_state,
+ zoopla_enabled,
)
from openrent import WafChallengeError
from openrent import load_cookies as load_openrent_cookies
from openrent import make_client as make_openrent_client
from openrent import search_outcode as openrent_search_outcode
from rightmove import resolve_outcode_id, search_outcode
+from zoopla import TurnstileError
+from zoopla import launch_browser as launch_zoopla_browser
+from zoopla import search_outcode as zoopla_search_outcode
from spatial import PostcodeSpatialIndex
from storage import write_parquet
@@ -44,10 +61,11 @@ class ScrapeStatus:
outcodes_total: int = 0
properties_buy: int = 0
properties_rent: int = 0
- # Per-source counts for current channel
+ # Per-source counts (combined across channels)
rm_properties: int = 0
hk_properties: int = 0
or_properties: int = 0
+ zp_properties: int = 0
errors: list[str] = field(default_factory=list)
started_at: float = 0.0
finished_at: float = 0.0
@@ -63,14 +81,26 @@ def _sync_gauges() -> None:
scrape_state.labels(state=state).set(1 if status.state == state else 0)
scrape_outcodes_done.set(status.outcodes_done)
scrape_outcodes_total.set(status.outcodes_total)
- # Total properties (both sources combined)
- scrape_properties_total.labels(channel="buy", source="total").set(status.properties_buy)
- scrape_properties_total.labels(channel="rent", source="total").set(status.properties_rent)
- # Per-source breakdown for current channel
- ch = "buy" if status.channel == "BUY" else "rent"
- scrape_properties_total.labels(channel=ch, source="rightmove").set(status.rm_properties)
- scrape_properties_total.labels(channel=ch, source="homecouk").set(status.hk_properties)
- scrape_properties_total.labels(channel=ch, source="openrent").set(status.or_properties)
+ scrape_properties_total.labels(channel="buy", source="total").set(
+ status.properties_buy
+ )
+ scrape_properties_total.labels(channel="rent", source="total").set(
+ status.properties_rent
+ )
+ # Per-source totals (across both channels)
+ for ch in ("buy", "rent"):
+ scrape_properties_total.labels(channel=ch, source="rightmove").set(
+ status.rm_properties
+ )
+ scrape_properties_total.labels(channel=ch, source="homecouk").set(
+ status.hk_properties
+ )
+ scrape_properties_total.labels(channel=ch, source="openrent").set(
+ status.or_properties
+ )
+ scrape_properties_total.labels(channel=ch, source="zoopla").set(
+ status.zp_properties
+ )
if status.started_at:
end = status.finished_at if status.finished_at else time.time()
scrape_elapsed_seconds.set(end - status.started_at)
@@ -86,7 +116,9 @@ def load_outcodes() -> list[str]:
log.info("England postcodes: %d", len(england))
outcodes = (
- england.select(pl.col("pcd").str.extract(r"^([A-Z]{1,2}\d[A-Z0-9]?)", 1).alias("outcode"))
+ england.select(
+ pl.col("pcd").str.extract(r"^([A-Z]{1,2}\d[A-Z0-9]?)", 1).alias("outcode")
+ )
.drop_nulls()
.get_column("outcode")
.unique()
@@ -101,7 +133,9 @@ def build_postcode_index() -> PostcodeSpatialIndex:
"""Build spatial index from arcgis England postcodes."""
log.info("Building postcode spatial index from %s", ARCGIS_PATH)
df = pl.read_parquet(ARCGIS_PATH, columns=["pcd", "ctry", "lat", "long"])
- england = df.filter(pl.col("ctry") == "E92000001").drop_nulls(subset=["lat", "long"])
+ england = df.filter(pl.col("ctry") == "E92000001").drop_nulls(
+ subset=["lat", "long"]
+ )
return PostcodeSpatialIndex(
england.get_column("lat").to_list(),
england.get_column("long").to_list(),
@@ -114,7 +148,9 @@ def build_postcode_coords() -> dict[str, tuple[float, float]]:
Used by OpenRent scraper to resolve coordinates from postcodes."""
log.info("Building postcode coords lookup from %s", ARCGIS_PATH)
df = pl.read_parquet(ARCGIS_PATH, columns=["pcd", "ctry", "lat", "long"])
- england = df.filter(pl.col("ctry") == "E92000001").drop_nulls(subset=["lat", "long"])
+ england = df.filter(pl.col("ctry") == "E92000001").drop_nulls(
+ subset=["lat", "long"]
+ )
coords: dict[str, tuple[float, float]] = {}
for pcd, lat, lng in zip(
england.get_column("pcd").to_list(),
@@ -126,6 +162,15 @@ def build_postcode_coords() -> dict[str, tuple[float, float]]:
return coords
+def _fmt_elapsed(seconds: float) -> str:
+ """Format seconds as e.g. '2h13m' or '5m32s'."""
+ h, rem = divmod(int(seconds), 3600)
+ m, s = divmod(rem, 60)
+ if h:
+ return f"{h}h{m:02d}m"
+ return f"{m}m{s:02d}s"
+
+
def _dedup_key(p: dict) -> tuple:
"""Composite key for cross-source deduplication: (postcode, bedrooms, price).
Two listings on different portals for the same physical property will share
@@ -133,28 +178,89 @@ def _dedup_key(p: dict) -> tuple:
return (p.get("Postcode", ""), p.get("Bedrooms", 0), p.get("price", 0))
+class _Progress:
+ """Thread-safe progress tracker for parallel source workers."""
+
+ def __init__(self):
+ self._counts: dict[str, int] = {}
+ self._lock = threading.Lock()
+
+ def update(self, source: str, done: int) -> None:
+ with self._lock:
+ self._counts[source] = done
+
+ def snapshot(self) -> dict[str, int]:
+ with self._lock:
+ return dict(self._counts)
+
+
+def _merge_channel(
+ rm_props: list[dict],
+ hk_props: list[dict],
+ or_props: list[dict],
+ zp_props: list[dict],
+) -> tuple[dict[str, dict], dict[str, int], int]:
+ """Merge properties from all sources for one channel with cross-source dedup.
+
+ Rightmove has priority; other sources are checked for duplicates.
+ Returns (all_properties_by_id, per_source_counts, total_dedup_count).
+ """
+ all_properties: dict[str, dict] = {}
+ seen_keys: set[tuple] = set()
+ counts = {"rm": 0, "hk": 0, "or": 0, "zp": 0}
+ total_dedup = 0
+
+ # Rightmove first (priority source)
+ for p in rm_props:
+ pid = p["id"]
+ if pid not in all_properties:
+ all_properties[pid] = p
+ seen_keys.add(_dedup_key(p))
+ counts["rm"] += 1
+
+ # Other sources (check for cross-source duplicates)
+ for source, props in [("hk", hk_props), ("or", or_props), ("zp", zp_props)]:
+ for p in props:
+ pid = p["id"]
+ key = _dedup_key(p)
+ if pid in all_properties or key in seen_keys:
+ total_dedup += 1
+ continue
+ all_properties[pid] = p
+ seen_keys.add(key)
+ counts[source] += 1
+
+ return all_properties, counts, total_dedup
+
+
def run_scrape(
outcodes: list[str],
pc_index: PostcodeSpatialIndex,
pc_coords: dict[str, tuple[float, float]] | None = None,
) -> None:
- """Main scrape loop — runs in background thread.
- Scrapes Rightmove, home.co.uk, and OpenRent, merging into one dataset."""
+ """Main scrape orchestrator — runs all sources in parallel threads.
+
+ Each source (Rightmove, home.co.uk, OpenRent, Zoopla) gets its own thread
+ that iterates all outcodes for both BUY and RENT channels. Results are
+ merged with cross-source deduplication after all workers complete.
+ """
global status
with status_lock:
status.state = "running"
status.started_at = time.time()
+ status.finished_at = 0.0
status.errors = []
status.properties_buy = 0
status.properties_rent = 0
+ status.channel = ""
+ status.outcode = ""
_sync_gauges()
- # Shuffle for geographic diversity
shuffled = list(outcodes)
random.seed(SEED)
random.shuffle(shuffled)
- if not SCRAPE_RIGHTMOVE and not SCRAPE_HOMECOUK and not SCRAPE_OPENRENT:
+ if not any([SCRAPE_RIGHTMOVE, SCRAPE_HOMECOUK, SCRAPE_OPENRENT, SCRAPE_ZOOPLA]):
log.warning("All scrapers disabled — nothing to do")
with status_lock:
status.state = "done"
@@ -162,240 +268,422 @@ def run_scrape(
_sync_gauges()
return
- client = make_client() if SCRAPE_RIGHTMOVE else None
if not SCRAPE_RIGHTMOVE:
log.info("Rightmove scraping DISABLED (SCRAPE_RIGHTMOVE=false)")
-
- # home.co.uk: must be enabled via SCRAPE_HOMECOUK + cookies available
- hk_client = None
- hk_failed = False
if not SCRAPE_HOMECOUK:
log.info("home.co.uk scraping DISABLED (SCRAPE_HOMECOUK=false)")
homecouk_enabled.set(0)
- else:
- hk_result = load_homecouk_cookies()
- hk_client = make_homecouk_client(*hk_result) if hk_result else None
- if hk_client:
- log.info("home.co.uk scraping ENABLED")
- homecouk_enabled.set(1)
- else:
- log.info("home.co.uk scraping DISABLED (need FlareSolverr or HOMECOUK_CF_CLEARANCE + HOMECOUK_SESSION)")
- homecouk_enabled.set(0)
-
- # OpenRent: must be enabled via SCRAPE_OPENRENT + cookies available
- or_client = None
- or_failed = False
if not SCRAPE_OPENRENT:
log.info("OpenRent scraping DISABLED (SCRAPE_OPENRENT=false)")
openrent_enabled.set(0)
- else:
- or_result = load_openrent_cookies()
- or_client = make_openrent_client(*or_result) if or_result else None
- if or_client:
- log.info("OpenRent scraping ENABLED")
- openrent_enabled.set(1)
- else:
- log.info("OpenRent scraping DISABLED (need FlareSolverr or OPENRENT_WAF_TOKEN)")
- openrent_enabled.set(0)
+ if not SCRAPE_ZOOPLA:
+ log.info("Zoopla scraping DISABLED (SCRAPE_ZOOPLA=false)")
+ zoopla_enabled.set(0)
- # Build postcode coords if OpenRent is active and caller didn't provide them
- if or_client and pc_coords is None:
+ # Build postcode coords if needed for OpenRent/Zoopla
+ if (SCRAPE_OPENRENT or SCRAPE_ZOOPLA) and pc_coords is None:
pc_coords = build_postcode_coords()
- try:
- for channel_cfg in CHANNELS:
- channel_name = channel_cfg["channel"]
- file_suffix = "buy" if channel_name == "BUY" else "rent"
- all_properties: dict[str, dict] = {} # dedup by id
- seen_dedup_keys: set[tuple] = set() # cross-source dedup by (postcode, beds, price)
- rm_count = 0 # Rightmove properties this channel
- hk_count = 0 # home.co.uk properties this channel
- hk_dedup_count = 0 # home.co.uk skipped as cross-source duplicates
- or_count = 0 # OpenRent properties this channel
- or_dedup_count = 0 # OpenRent skipped as cross-source duplicates
+ # Per-source result containers: {channel_name: [properties]}
+ # Each list is only written by its owning source thread.
+ rm_results: dict[str, list] = {"BUY": [], "RENT": []}
+ hk_results: dict[str, list] = {"BUY": [], "RENT": []}
+ or_results: dict[str, list] = {"BUY": [], "RENT": []}
+ zp_results: dict[str, list] = {"BUY": [], "RENT": []}
- with status_lock:
- status.channel = channel_name
- status.outcodes_done = 0
- status.outcodes_total = len(shuffled)
- status.rm_properties = 0
- status.hk_properties = 0
- status.or_properties = 0
+ progress = _Progress()
- log.info("=== Starting %s channel (%d outcodes) ===", channel_name, len(shuffled))
+ # --- Source worker closures ---
+ # Each worker owns its client lifecycle and iterates all outcodes for both
+ # channels. On auth failure, it refreshes cookies and continues. On fatal
+ # failure, it marks itself as done and returns partial results.
+ def rm_worker():
+ client = make_client()
+ try:
for i, outcode in enumerate(shuffled):
- with status_lock:
- status.outcode = outcode
- status.outcodes_done = i
+ try:
+ outcode_id = resolve_outcode_id(client, outcode)
+ except Exception as e:
+ log.error("Rightmove %s ID lookup: %s", outcode, e)
+ scrape_errors_total.labels(source="rightmove").inc()
+ progress.update("rm", i + 1)
+ time.sleep(DELAY_BETWEEN_OUTCODES)
+ continue
- log.debug("Outcode %s (%d/%d) — %d properties so far",
- outcode, i + 1, len(shuffled), len(all_properties))
+ if not outcode_id:
+ log.debug("No Rightmove ID for %s, skipping", outcode)
+ progress.update("rm", i + 1)
+ time.sleep(DELAY_BETWEEN_OUTCODES)
+ continue
- made_requests = False
-
- # --- Rightmove ---
- if SCRAPE_RIGHTMOVE:
- made_requests = True
+ for ch_cfg in CHANNELS:
+ ch = ch_cfg["channel"]
try:
- outcode_id = resolve_outcode_id(client, outcode)
- if not outcode_id:
- log.debug("No Rightmove ID for outcode %s, skipping", outcode)
- else:
- props = search_outcode(client, outcode_id, outcode, channel_cfg, pc_index)
- for p in props:
- pid = p["id"]
- if pid not in all_properties:
- all_properties[pid] = p
- seen_dedup_keys.add(_dedup_key(p))
- rm_count += 1
- except Exception as e:
- msg = f"Error scraping Rightmove {outcode}/{channel_name}: {e}"
- log.error(msg)
- scrape_errors_total.labels(source="rightmove").inc()
- with status_lock:
- status.errors.append(msg)
-
- # --- home.co.uk ---
- if hk_client and not hk_failed:
- made_requests = True
- try:
- hk_props = homecouk_search_outcode(
- hk_client, outcode, channel_name, pc_index,
+ props = search_outcode(
+ client, outcode_id, outcode, ch_cfg, pc_index
)
- for p in hk_props:
- pid = p["id"]
- key = _dedup_key(p)
- if pid in all_properties or key in seen_dedup_keys:
- hk_dedup_count += 1
- cross_source_dedup_total.labels(
- channel="buy" if channel_name == "BUY" else "rent",
- ).inc()
- continue
- all_properties[pid] = p
- seen_dedup_keys.add(key)
- hk_count += 1
- if hk_props:
- log.info("home.co.uk %s: +%d properties", outcode, len(hk_props))
+ rm_results[ch].extend(props)
+ except Exception as e:
+ log.error("Rightmove %s/%s: %s", outcode, ch, e)
+ scrape_errors_total.labels(source="rightmove").inc()
+
+ progress.update("rm", i + 1)
+ time.sleep(DELAY_BETWEEN_OUTCODES)
+ except Exception as e:
+ log.exception("Fatal Rightmove error: %s", e)
+ with status_lock:
+ status.errors.append(f"Fatal Rightmove: {e}")
+ finally:
+ client.close()
+
+ def hk_worker():
+ hk_result = load_homecouk_cookies()
+ if not hk_result:
+ log.info("home.co.uk DISABLED (no cookies available)")
+ homecouk_enabled.set(0)
+ progress.update("hk", len(shuffled))
+ return
+ client = make_homecouk_client(*hk_result)
+ log.info("home.co.uk scraping ENABLED")
+ homecouk_enabled.set(1)
+ try:
+ for i, outcode in enumerate(shuffled):
+ for ch_cfg in CHANNELS:
+ ch = ch_cfg["channel"]
+ try:
+ props = homecouk_search_outcode(
+ client, outcode, ch, pc_index
+ )
+ hk_results[ch].extend(props)
+ if props:
+ log.info("home.co.uk %s: +%d properties", outcode, len(props))
except CookiesExpiredError:
- log.warning("home.co.uk cookies expired — attempting refresh via FlareSolverr")
- hk_client.close()
- hk_result = load_homecouk_cookies()
- if hk_result:
- hk_client = make_homecouk_client(*hk_result)
+ log.warning(
+ "home.co.uk cookies expired — attempting refresh"
+ )
+ client.close()
+ hk_new = load_homecouk_cookies()
+ if hk_new:
+ client = make_homecouk_client(*hk_new)
log.info("home.co.uk cookies refreshed, continuing")
cookie_refreshes_total.labels(result="success").inc()
else:
- log.warning("Cookie refresh failed, disabling home.co.uk for rest of scrape")
- hk_client = None
- hk_failed = True
+ log.warning(
+ "Cookie refresh failed, disabling home.co.uk"
+ )
homecouk_enabled.set(0)
cookie_refreshes_total.labels(result="failure").inc()
with status_lock:
- status.errors.append("home.co.uk cookies expired and refresh failed")
+ status.errors.append(
+ "home.co.uk cookies expired and refresh failed"
+ )
+ progress.update("hk", len(shuffled))
+ return
except Exception as e:
- msg = f"Error scraping home.co.uk {outcode}/{channel_name}: {e}"
- log.error(msg)
+ log.error("home.co.uk %s/%s: %s", outcode, ch, e)
scrape_errors_total.labels(source="homecouk").inc()
- with status_lock:
- status.errors.append(msg)
- # --- OpenRent (RENT channel only) ---
- if or_client and not or_failed and channel_name == "RENT":
- made_requests = True
- try:
- or_props = openrent_search_outcode(
- or_client, outcode, pc_index, pc_coords,
- )
- for p in or_props:
- pid = p["id"]
- key = _dedup_key(p)
- if pid in all_properties or key in seen_dedup_keys:
- or_dedup_count += 1
- cross_source_dedup_total.labels(channel="rent").inc()
- continue
- all_properties[pid] = p
- seen_dedup_keys.add(key)
- or_count += 1
- if or_props:
- log.info("OpenRent %s: +%d properties", outcode, len(or_props))
- except WafChallengeError:
- log.warning("OpenRent WAF cookies expired — attempting refresh via FlareSolverr")
- or_client.close()
- or_result = load_openrent_cookies()
- if or_result:
- or_client = make_openrent_client(*or_result)
- log.info("OpenRent cookies refreshed, continuing")
- cookie_refreshes_total.labels(result="success").inc()
- else:
- log.warning("Cookie refresh failed, disabling OpenRent for rest of scrape")
- or_client = None
- or_failed = True
- openrent_enabled.set(0)
- cookie_refreshes_total.labels(result="failure").inc()
- with status_lock:
- status.errors.append("OpenRent WAF cookies expired and refresh failed")
- except Exception as e:
- msg = f"Error scraping OpenRent {outcode}/{channel_name}: {e}"
- log.error(msg)
- scrape_errors_total.labels(source="openrent").inc()
- with status_lock:
- status.errors.append(msg)
+ progress.update("hk", i + 1)
+ time.sleep(DELAY_BETWEEN_OUTCODES)
+ except Exception as e:
+ log.exception("Fatal home.co.uk error: %s", e)
+ with status_lock:
+ status.errors.append(f"Fatal home.co.uk: {e}")
+ finally:
+ try:
+ client.close()
+ except Exception:
+ pass
- with status_lock:
- if channel_name == "BUY":
- status.properties_buy = len(all_properties)
+ def or_worker():
+ or_result = load_openrent_cookies()
+ if not or_result:
+ log.info("OpenRent DISABLED (no cookies available)")
+ openrent_enabled.set(0)
+ progress.update("or", len(shuffled))
+ return
+ client = make_openrent_client(*or_result)
+ log.info("OpenRent scraping ENABLED")
+ openrent_enabled.set(1)
+ try:
+ for i, outcode in enumerate(shuffled):
+ # OpenRent is RENT-only
+ try:
+ props = openrent_search_outcode(
+ client, outcode, pc_index, pc_coords
+ )
+ or_results["RENT"].extend(props)
+ if props:
+ log.info("OpenRent %s: +%d properties", outcode, len(props))
+ except WafChallengeError:
+ log.warning(
+ "OpenRent WAF cookies expired — attempting refresh"
+ )
+ client.close()
+ or_new = load_openrent_cookies()
+ if or_new:
+ client = make_openrent_client(*or_new)
+ log.info("OpenRent cookies refreshed, continuing")
+ cookie_refreshes_total.labels(result="success").inc()
else:
- status.properties_rent = len(all_properties)
- status.rm_properties = rm_count
- status.hk_properties = hk_count
- status.or_properties = or_count
- _sync_gauges()
+ log.warning(
+ "Cookie refresh failed, disabling OpenRent"
+ )
+ openrent_enabled.set(0)
+ cookie_refreshes_total.labels(result="failure").inc()
+ with status_lock:
+ status.errors.append(
+ "OpenRent WAF cookies expired and refresh failed"
+ )
+ progress.update("or", len(shuffled))
+ return
+ except Exception as e:
+ log.error("OpenRent %s: %s", outcode, e)
+ scrape_errors_total.labels(source="openrent").inc()
- log.info("Outcode %s: total %d (rm: %d, hk: %d, or: %d)",
- outcode, len(all_properties), rm_count, hk_count, or_count)
+ progress.update("or", i + 1)
+ time.sleep(DELAY_BETWEEN_OUTCODES)
+ except Exception as e:
+ log.exception("Fatal OpenRent error: %s", e)
+ with status_lock:
+ status.errors.append(f"Fatal OpenRent: {e}")
+ finally:
+ try:
+ client.close()
+ except Exception:
+ pass
- if made_requests and i < len(shuffled) - 1:
- time.sleep(DELAY_BETWEEN_OUTCODES)
+ def zp_worker():
+ try:
+ browser, page = launch_zoopla_browser()
+ log.info("Zoopla scraping ENABLED (Camoufox browser launched)")
+ zoopla_enabled.set(1)
+ except TurnstileError:
+ log.warning("Zoopla Cloudflare Turnstile failed — disabling Zoopla")
+ zoopla_enabled.set(0)
+ progress.update("zp", len(shuffled))
+ return
+ except Exception as e:
+ log.warning("Zoopla browser launch failed: %s — disabling Zoopla", e)
+ zoopla_enabled.set(0)
+ progress.update("zp", len(shuffled))
+ return
- # Write parquet
- deduped = list(all_properties.values())
+ try:
+ for i, outcode in enumerate(shuffled):
+ for ch_cfg in CHANNELS:
+ ch = ch_cfg["channel"]
+ try:
+ props = zoopla_search_outcode(
+ page, outcode, ch, pc_index, pc_coords
+ )
+ zp_results[ch].extend(props)
+ if props:
+ log.info("Zoopla %s: +%d properties", outcode, len(props))
+ except TurnstileError:
+ log.warning(
+ "Zoopla Turnstile challenge — relaunching browser"
+ )
+ try:
+ browser.close()
+ except Exception:
+ pass
+ try:
+ browser, page = launch_zoopla_browser()
+ log.info("Zoopla browser relaunched, continuing")
+ except Exception:
+ log.warning(
+ "Browser relaunch failed, disabling Zoopla"
+ )
+ zoopla_enabled.set(0)
+ with status_lock:
+ status.errors.append(
+ "Zoopla Cloudflare challenge failed and relaunch failed"
+ )
+ progress.update("zp", len(shuffled))
+ return
+ except Exception as e:
+ log.error("Zoopla %s/%s: %s", outcode, ch, e)
+ scrape_errors_total.labels(source="zoopla").inc()
+
+ progress.update("zp", i + 1)
+ time.sleep(DELAY_BETWEEN_OUTCODES)
+ except Exception as e:
+ log.exception("Fatal Zoopla error: %s", e)
+ with status_lock:
+ status.errors.append(f"Fatal Zoopla: {e}")
+ finally:
+ try:
+ browser.close()
+ except Exception:
+ pass
+
+ # --- Launch worker threads ---
+
+ active_sources: list[str] = []
+ threads: list[threading.Thread] = []
+
+ if SCRAPE_RIGHTMOVE:
+ threads.append(threading.Thread(target=rm_worker, name="scrape-rm", daemon=True))
+ active_sources.append("rm")
+ if SCRAPE_HOMECOUK:
+ threads.append(threading.Thread(target=hk_worker, name="scrape-hk", daemon=True))
+ active_sources.append("hk")
+ if SCRAPE_OPENRENT:
+ threads.append(threading.Thread(target=or_worker, name="scrape-or", daemon=True))
+ active_sources.append("or")
+ if SCRAPE_ZOOPLA:
+ threads.append(threading.Thread(target=zp_worker, name="scrape-zp", daemon=True))
+ active_sources.append("zp")
+
+ log.info(
+ "=== Starting scrape: %d outcodes, sources: %s ===",
+ len(shuffled),
+ ", ".join(active_sources),
+ )
+
+ for t in threads:
+ t.start()
+
+ # --- Monitor progress while workers run ---
+
+ scrape_start = time.time()
+ last_log = 0.0
+
+ try:
+ while any(t.is_alive() for t in threads):
+ snap = progress.snapshot()
+ min_done = min(
+ (snap.get(s, 0) for s in active_sources), default=0
+ )
+
+ # Count properties across sources (safe: only one thread writes each list)
+ total_buy = sum(
+ len(r["BUY"]) for r in [rm_results, hk_results, or_results, zp_results]
+ )
+ total_rent = sum(
+ len(r["RENT"]) for r in [rm_results, hk_results, or_results, zp_results]
+ )
+
+ with status_lock:
+ status.outcodes_done = min_done
+ status.outcodes_total = len(shuffled)
+ status.properties_buy = total_buy
+ status.properties_rent = total_rent
+ status.rm_properties = len(rm_results["BUY"]) + len(rm_results["RENT"])
+ status.hk_properties = len(hk_results["BUY"]) + len(hk_results["RENT"])
+ status.or_properties = len(or_results["RENT"])
+ status.zp_properties = len(zp_results["BUY"]) + len(zp_results["RENT"])
+ _sync_gauges()
+
+ # Log progress every 30 seconds
+ now = time.time()
+ if now - last_log >= 30:
+ elapsed = now - scrape_start
+ per_source = ", ".join(
+ f"{s}:{snap.get(s, 0)}" for s in active_sources
+ )
+ log.info(
+ "Progress: %d/%d outcodes (%s), %d buy + %d rent props, %s elapsed",
+ min_done,
+ len(shuffled),
+ per_source,
+ total_buy,
+ total_rent,
+ _fmt_elapsed(elapsed),
+ )
+ last_log = now
+
+ time.sleep(5)
+ except Exception as e:
+ log.exception("Monitor loop error: %s", e)
+
+ for t in threads:
+ t.join()
+
+ log.info("All source workers completed")
+
+ # --- Merge results per channel and write parquet ---
+
+ try:
+ for ch_cfg in CHANNELS:
+ ch = ch_cfg["channel"]
+ file_suffix = "buy" if ch == "BUY" else "rent"
+
+ merged, counts, total_dedup = _merge_channel(
+ rm_results[ch],
+ hk_results[ch],
+ or_results[ch],
+ zp_results[ch],
+ )
+
+ # Update cross-source dedup counter
+ ch_label = "buy" if ch == "BUY" else "rent"
+ if total_dedup:
+ cross_source_dedup_total.labels(channel=ch_label).inc(total_dedup)
+
+ deduped = list(merged.values())
output_path = DATA_DIR / f"online_listings_{file_suffix}.parquet"
write_parquet(deduped, output_path, channel=file_suffix)
with status_lock:
- if channel_name == "BUY":
+ if ch == "BUY":
status.properties_buy = len(deduped)
else:
status.properties_rent = len(deduped)
- status.outcodes_done = len(shuffled)
_sync_gauges()
log.info(
- "=== %s channel complete: %d unique (rm: %d, hk: %d, or: %d, cross-dedup: %d) ===",
- channel_name, len(deduped), rm_count, hk_count, or_count,
- hk_dedup_count + or_dedup_count,
+ "=== %s complete: %d unique (rm:%d hk:%d or:%d zp:%d, cross-dedup:%d) ===",
+ ch,
+ len(deduped),
+ counts["rm"],
+ counts["hk"],
+ counts["or"],
+ counts["zp"],
+ total_dedup,
)
with status_lock:
status.state = "done"
status.finished_at = time.time()
+ status.outcodes_done = len(shuffled)
_sync_gauges()
elapsed = status.finished_at - status.started_at
- log.info("Scrape complete in %.0fs — buy: %d, rent: %d",
- elapsed, status.properties_buy, status.properties_rent)
+ log.info(
+ "Scrape complete in %s — buy: %d, rent: %d",
+ _fmt_elapsed(elapsed),
+ status.properties_buy,
+ status.properties_rent,
+ )
+
+ # Trigger server data reload
+ if RELOAD_URL:
+ try:
+ log.info("Triggering server reload at %s", RELOAD_URL)
+ resp = httpx.post(RELOAD_URL, timeout=300)
+ if resp.is_success:
+ body = resp.json()
+ log.info(
+ "Server reload complete: %d rows, %d features, %dms",
+ body.get("rows", 0),
+ body.get("features", 0),
+ body.get("elapsed_ms", 0),
+ )
+ else:
+ log.warning(
+ "Server reload failed (%d): %s",
+ resp.status_code,
+ resp.text[:200],
+ )
+ except Exception as e:
+ log.warning("Server reload request failed: %s", e)
except Exception as e:
- log.exception("Fatal scrape error")
+ log.exception("Fatal scrape error during merge/write")
with status_lock:
status.state = "error"
status.errors.append(f"Fatal: {e}")
status.finished_at = time.time()
_sync_gauges()
- finally:
- if client:
- client.close()
- if hk_client:
- hk_client.close()
- if or_client:
- or_client.close()
diff --git a/finder/spatial.py b/finder/spatial.py
index ad17a47..1216796 100644
--- a/finder/spatial.py
+++ b/finder/spatial.py
@@ -11,12 +11,16 @@ class PostcodeSpatialIndex:
"""Grid-based spatial index over arcgis postcodes for nearest-lookup."""
def __init__(self, lats: list[float], lngs: list[float], postcodes: list[str]):
- self.grid: dict[tuple[int, int], list[tuple[float, float, str]]] = defaultdict(list)
+ self.grid: dict[tuple[int, int], list[tuple[float, float, str]]] = defaultdict(
+ list
+ )
for lat, lng, pcd in zip(lats, lngs, postcodes):
gx = int(math.floor(lng / GRID_CELL_SIZE))
gy = int(math.floor(lat / GRID_CELL_SIZE))
self.grid[(gx, gy)].append((lat, lng, pcd))
- log.info("Postcode spatial index: %d cells, %d postcodes", len(self.grid), len(lats))
+ log.info(
+ "Postcode spatial index: %d cells, %d postcodes", len(self.grid), len(lats)
+ )
def nearest(self, lat: float, lng: float) -> str | None:
gx = int(math.floor(lng / GRID_CELL_SIZE))
diff --git a/finder/storage.py b/finder/storage.py
index ded3f42..9854188 100644
--- a/finder/storage.py
+++ b/finder/storage.py
@@ -25,7 +25,11 @@ def write_parquet(properties: list[dict], path: Path, channel: str) -> None:
if fvd:
try:
dt = datetime.fromisoformat(fvd.replace("Z", "+00:00"))
- listing_dates.append(dt.replace(tzinfo=None))
+ # Convert to UTC naive datetime for consistent storage
+ if dt.tzinfo is not None:
+ from datetime import timezone
+ dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
+ listing_dates.append(dt)
except (ValueError, TypeError):
listing_dates.append(None)
else:
@@ -60,9 +64,7 @@ def write_parquet(properties: list[dict], path: Path, channel: str) -> None:
"Property type": [p["Property type"] for p in properties],
"Property sub-type": [p["Property sub-type"] for p in properties],
"Price qualifier": [p["Price qualifier"] for p in properties],
- "Total floor area (sqm)": [
- p["Total floor area (sqm)"] for p in properties
- ],
+ "Total floor area (sqm)": [p["Total floor area (sqm)"] for p in properties],
"Listing URL": [p["Listing URL"] for p in properties],
"Listing features": [p["Listing features"] for p in properties],
"Listing date": listing_dates,
diff --git a/finder/transform.py b/finder/transform.py
index 3f847fc..143d07a 100644
--- a/finder/transform.py
+++ b/finder/transform.py
@@ -51,9 +51,19 @@ def fix_coords(lat: float, lng: float) -> tuple[float, float]:
if 49 <= lat <= 56 and -7 <= lng <= 2:
return lat, lng
if 49 <= lng <= 56 and -7 <= lat <= 2:
- log.debug("Swapping reversed coords: lat=%.4f lng=%.4f → lat=%.4f lng=%.4f", lat, lng, lng, lat)
+ log.debug(
+ "Swapping reversed coords: lat=%.4f lng=%.4f → lat=%.4f lng=%.4f",
+ lat,
+ lng,
+ lng,
+ lat,
+ )
return lng, lat
- log.warning("Coords outside England bounds even after swap attempt: lat=%.4f lng=%.4f", lat, lng)
+ log.warning(
+ "Coords outside England bounds even after swap attempt: lat=%.4f lng=%.4f",
+ lat,
+ lng,
+ )
return lat, lng
@@ -66,7 +76,9 @@ def normalize_price(amount: int, frequency: str) -> int:
return amount
-def transform_property(prop: dict, outcode: str, pc_index: PostcodeSpatialIndex) -> dict | None:
+def transform_property(
+ prop: dict, outcode: str, pc_index: PostcodeSpatialIndex
+) -> dict | None:
"""Transform a raw Rightmove property dict into our output schema."""
loc = prop.get("location")
if not loc:
@@ -86,13 +98,19 @@ def transform_property(prop: dict, outcode: str, pc_index: PostcodeSpatialIndex)
price = normalize_price(int(amount), frequency)
display_prices = price_obj.get("displayPrices", [])
- price_qualifier = display_prices[0].get("displayPriceQualifier", "") if display_prices else ""
+ price_qualifier = (
+ display_prices[0].get("displayPriceQualifier", "") if display_prices else ""
+ )
sub_type = prop.get("propertySubType", "")
bedrooms = prop.get("bedrooms", 0) or 0
bathrooms = prop.get("bathrooms", 0) or 0
- key_features = [kf.get("description", "") for kf in prop.get("keyFeatures", []) if kf.get("description")]
+ key_features = [
+ kf.get("description", "")
+ for kf in prop.get("keyFeatures", [])
+ if kf.get("description")
+ ]
postcode = pc_index.nearest(lat, lng)
if not postcode:
diff --git a/finder/uv.lock b/finder/uv.lock
index b443f53..26df5be 100644
--- a/finder/uv.lock
+++ b/finder/uv.lock
@@ -15,6 +15,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
+[[package]]
+name = "apify-fingerprint-datapoints"
+version = "0.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bb/a9/586b7ebdd682c047cd0b551dc7e154bb1480f8f6548154708e9a6c7844db/apify_fingerprint_datapoints-0.11.0.tar.gz", hash = "sha256:3f905c392b11a27fb59ccfe40891c166abd737ab9c6209733f102bbb3b302515", size = 969830, upload-time = "2026-03-01T01:00:04.737Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/38/9483eb52fc0f00039c684af627f8a8f994a8a99e8eceb869ba93b3fd740b/apify_fingerprint_datapoints-0.11.0-py3-none-any.whl", hash = "sha256:333340ccc3e520f19b5561e95d7abe2b31702e61d34b6247b328c9b8c93fbe1d", size = 726498, upload-time = "2026-03-01T01:00:03.103Z" },
+]
+
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
@@ -37,6 +46,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
+[[package]]
+name = "browserforge"
+version = "1.2.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "apify-fingerprint-datapoints" },
+ { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/6f/8975af88d203efd70cc69477ebac702babef38201d04621c9583f2508f25/browserforge-1.2.4.tar.gz", hash = "sha256:05686473793769856ebd3528c69071f5be0e511260993e8b2ba839863711a0c4", size = 36700, upload-time = "2026-02-03T02:52:09.721Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/35/ce962f738ae28ffce6293e7607b129075633e6bb185a5ab87e49246eedc2/browserforge-1.2.4-py3-none-any.whl", hash = "sha256:fb1c14e62ac09de221dcfc73074200269f697596c642cb200ceaab1127a17542", size = 37890, upload-time = "2026-02-03T02:52:08.745Z" },
+]
+
+[[package]]
+name = "camoufox"
+version = "0.4.11"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "browserforge" },
+ { name = "click" },
+ { name = "language-tags" },
+ { name = "lxml" },
+ { name = "numpy" },
+ { name = "orjson" },
+ { name = "platformdirs" },
+ { name = "playwright" },
+ { name = "pysocks" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "screeninfo" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+ { name = "ua-parser" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d3/15/e0a1b586e354ea6b8d6612717bf4372aaaa6753444d5d006caf0bb116466/camoufox-0.4.11.tar.gz", hash = "sha256:0a2c9d24ac5070c104e7c2b125c0a3937f70efa416084ef88afe94c32a72eebe", size = 64409, upload-time = "2025-01-29T09:33:20.019Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c6/7b/a2f099a5afb9660271b3f20f6056ba679e7ab4eba42682266a65d5730f7e/camoufox-0.4.11-py3-none-any.whl", hash = "sha256:83864d434d159a7566990aa6524429a8d1a859cbf84d2f64ef4a9f29e7d2e5ff", size = 71628, upload-time = "2025-01-29T09:33:18.558Z" },
+]
+
[[package]]
name = "certifi"
version = "2026.2.25"
@@ -103,6 +151,79 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
+[[package]]
+name = "charset-normalizer"
+version = "3.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
+ { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
+ { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
+ { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
+ { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
+ { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
+ { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
+ { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
+ { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
+ { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
+ { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
+ { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
+ { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
+ { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
+ { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
+ { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
+ { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
+ { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
+ { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
+ { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
+ { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
+ { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
+ { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
+ { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
+ { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
+ { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
+ { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
+]
+
[[package]]
name = "click"
version = "8.3.1"
@@ -147,6 +268,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" },
]
+[[package]]
+name = "cython"
+version = "3.2.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/91/85/7574c9cd44b69a27210444b6650f6477f56c75fee1b70d7672d3e4166167/cython-3.2.4.tar.gz", hash = "sha256:84226ecd313b233da27dc2eb3601b4f222b8209c3a7216d8733b031da1dc64e6", size = 3280291, upload-time = "2026-01-04T14:14:14.473Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/4d/1eb0c7c196a136b1926f4d7f0492a96c6fabd604d77e6cd43b56a3a16d83/cython-3.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64d7f71be3dd6d6d4a4c575bb3a4674ea06d1e1e5e4cd1b9882a2bc40ed3c4c9", size = 2970064, upload-time = "2026-01-04T14:15:08.567Z" },
+ { url = "https://files.pythonhosted.org/packages/18/b5/1cfca43b7d20a0fdb1eac67313d6bb6b18d18897f82dd0f17436bdd2ba7f/cython-3.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:28e8075087a59756f2d059273184b8b639fe0f16cf17470bd91c39921bc154e0", size = 2960506, upload-time = "2026-01-04T14:15:16.733Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/d7/3bda3efce0c5c6ce79cc21285dbe6f60369c20364e112f5a506ee8a1b067/cython-3.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4b4fd5332ab093131fa6172e8362f16adef3eac3179fd24bbdc392531cb82fa", size = 2971496, upload-time = "2026-01-04T14:15:25.038Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/8b/fd393f0923c82be4ec0db712fffb2ff0a7a131707b842c99bf24b549274d/cython-3.2.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36bf3f5eb56d5281aafabecbaa6ed288bc11db87547bba4e1e52943ae6961ccf", size = 2875622, upload-time = "2026-01-04T14:15:39.749Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/fa/d3c15189f7c52aaefbaea76fb012119b04b9013f4bf446cb4eb4c26c4e6b/cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c", size = 1257078, upload-time = "2026-01-04T14:14:12.373Z" },
+]
+
[[package]]
name = "fake-useragent"
version = "2.2.0"
@@ -162,11 +296,13 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "beautifulsoup4" },
+ { name = "camoufox" },
{ name = "curl-cffi" },
{ name = "fake-useragent" },
{ name = "flask" },
{ name = "httpx" },
{ name = "playwright" },
+ { name = "playwright-stealth" },
{ name = "polars" },
{ name = "prometheus-client" },
]
@@ -174,11 +310,13 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4" },
+ { name = "camoufox", specifier = ">=0.4.11" },
{ name = "curl-cffi" },
{ name = "fake-useragent", specifier = ">=2.2.0" },
{ name = "flask" },
{ name = "httpx" },
{ name = "playwright", specifier = ">=1.58.0" },
+ { name = "playwright-stealth", specifier = ">=2.0.2" },
{ name = "polars" },
{ name = "prometheus-client" },
]
@@ -310,6 +448,95 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
+[[package]]
+name = "language-tags"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/7e/b6a0efe4fee11e9742c1baaedf7c574084238a70b03c1d8eb2761383848f/language_tags-1.2.0.tar.gz", hash = "sha256:e934acba3e3dc85f867703eca421847a9ab7b7679b11b5d5cfd096febbf8bde6", size = 207901, upload-time = "2023-01-11T18:38:07.893Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/42/327554649ed2dd5ce59d3f5da176c7be20f9352c7c6c51597293660b7b08/language_tags-1.2.0-py3-none-any.whl", hash = "sha256:d815604622242fdfbbfd747b40c31213617fd03734a267f2e39ee4bd73c88722", size = 213449, upload-time = "2023-01-11T18:38:05.692Z" },
+]
+
+[[package]]
+name = "lxml"
+version = "6.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
+ { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
+ { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
+ { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
+ { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
+ { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
+ { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
+ { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
+ { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
+ { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
+ { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
+ { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
+ { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
+ { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
+ { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
+ { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
+ { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
+ { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
+ { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
+ { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
+ { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
+ { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
+ { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
+]
+
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -373,6 +600,129 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
+[[package]]
+name = "numpy"
+version = "2.4.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" },
+ { url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" },
+ { url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" },
+ { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" },
+ { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" },
+ { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" },
+ { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" },
+ { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" },
+ { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" },
+ { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" },
+ { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" },
+ { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" },
+ { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" },
+ { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
+]
+
+[[package]]
+name = "orjson"
+version = "3.11.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" },
+ { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" },
+ { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" },
+ { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" },
+ { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" },
+ { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" },
+ { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" },
+ { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" },
+ { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" },
+ { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" },
+ { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" },
+ { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" },
+ { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" },
+ { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" },
+ { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" },
+ { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" },
+ { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" },
+ { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" },
+ { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" },
+ { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" },
+ { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" },
+ { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" },
+ { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" },
+ { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.9.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
+]
+
[[package]]
name = "playwright"
version = "1.58.0"
@@ -392,6 +742,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
]
+[[package]]
+name = "playwright-stealth"
+version = "2.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "playwright" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/ee/871901103c7b2a12070011fd4d978191f8f962837bf8bb51847274f528fa/playwright_stealth-2.0.2.tar.gz", hash = "sha256:ac57e51873190da5e653e03720e948c8f0a3d06b098f1d56763103d23ee48143", size = 24902, upload-time = "2026-02-13T02:36:25.137Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/30/f95f087f4b071611a7f63a2a0c9af4df3ac046dae2a693bfdacd70512867/playwright_stealth-2.0.2-py3-none-any.whl", hash = "sha256:37a5733f481b9c0ad602cf71491aa5a7c96c2a2fe4fa1e7ab764d2cd35520f2f", size = 33209, upload-time = "2026-02-13T02:36:26.334Z" },
+]
+
[[package]]
name = "polars"
version = "1.39.0"
@@ -450,6 +812,118 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
+[[package]]
+name = "pyobjc-core"
+version = "12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" },
+ { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" },
+ { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" },
+]
+
+[[package]]
+name = "pyobjc-framework-cocoa"
+version = "12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyobjc-core" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" },
+ { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" },
+ { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" },
+ { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" },
+]
+
+[[package]]
+name = "pysocks"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+]
+
+[[package]]
+name = "screeninfo"
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cython", marker = "sys_platform == 'darwin'" },
+ { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ec/bb/e69e5e628d43f118e0af4fc063c20058faa8635c95a1296764acc8167e27/screeninfo-0.8.1.tar.gz", hash = "sha256:9983076bcc7e34402a1a9e4d7dabf3729411fd2abb3f3b4be7eba73519cd2ed1", size = 10666, upload-time = "2022-09-09T11:35:23.419Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/bf/c5205d480307bef660e56544b9e3d7ff687da776abb30c9cb3f330887570/screeninfo-0.8.1-py3-none-any.whl", hash = "sha256:e97d6b173856edcfa3bd282f81deb528188aff14b11ec3e195584e7641be733c", size = 12907, upload-time = "2022-09-09T11:35:21.351Z" },
+]
+
[[package]]
name = "soupsieve"
version = "2.8.3"
@@ -459,6 +933,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
+[[package]]
+name = "tqdm"
+version = "4.67.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -468,6 +954,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
+[[package]]
+name = "ua-parser"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ua-parser-builtins" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/70/0e/ed98be735bc89d5040e0c60f5620d0b8c04e9e7da99ed1459e8050e90a77/ua_parser-1.0.1.tar.gz", hash = "sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d", size = 728106, upload-time = "2025-02-01T14:13:32.508Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/37/be6dfbfa45719aa82c008fb4772cfe5c46db765a2ca4b6f524a1fdfee4d7/ua_parser-1.0.1-py3-none-any.whl", hash = "sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea", size = 31410, upload-time = "2025-02-01T14:13:28.458Z" },
+]
+
+[[package]]
+name = "ua-parser-builtins"
+version = "202603"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3e/6f/73a4d37deefb159556d39d654b5bad67b6874d1ad0b20b96fb5a04de3949/ua_parser_builtins-202603-py3-none-any.whl", hash = "sha256:67478397a68fac1a98fd0a31c416ea7c65a719141fc151d0211316f2cd337cc9", size = 89573, upload-time = "2026-03-01T20:50:02.491Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
[[package]]
name = "werkzeug"
version = "3.1.6"
diff --git a/finder/zoopla.py b/finder/zoopla.py
new file mode 100644
index 0000000..ecd8a4b
--- /dev/null
+++ b/finder/zoopla.py
@@ -0,0 +1,520 @@
+"""Zoopla (zoopla.co.uk) scraper — buy and rental properties.
+
+Zoopla is behind Cloudflare Turnstile (managed interactive challenge), which
+blocks all HTTP clients (curl_cffi, httpx) and even Playwright with stealth
+patches. Only Camoufox (an anti-fingerprinting Firefox fork) passes reliably.
+
+Zoopla uses Next.js App Router with React Server Components (RSC). Search
+result data is server-rendered in an RSC stream, not available via
+__NEXT_DATA__ or a JSON API. URL-based location slugs return 0 results —
+the working flow requires typing into the autocomplete input, selecting a
+suggestion, and clicking Search.
+
+Architecture:
+ Unlike the other scrapers which use HTTP clients per outcode, Zoopla keeps
+ a single Camoufox browser alive for the entire scrape. For each outcode, it:
+ 1. Clears and types the outcode into the search input
+ 2. Selects the first autocomplete suggestion
+ 3. Clicks Search
+ 4. Extracts listing data from the rendered DOM
+ 5. Handles pagination via ?pn=N parameter
+
+ The browser session replaces the cookie/client pattern used by other scrapers.
+"""
+
+import logging
+import re
+import time
+
+from constants import DELAY_BETWEEN_PAGES, PROPERTY_TYPE_MAP, ZOOPLA_BASE
+from metrics import zoopla_errors_total, zoopla_pages_scraped, zoopla_properties_scraped
+from spatial import PostcodeSpatialIndex
+
+log = logging.getLogger("zoopla")
+
+
+class TurnstileError(Exception):
+ """Raised when Cloudflare Turnstile challenge cannot be passed."""
+
+
+# Maximum search result pages to scrape per outcode (25 listings/page)
+MAX_PAGES_PER_OUTCODE = 10
+
+# JavaScript to extract listings from the rendered DOM.
+# Finds all detail links, walks up to the card container, and parses
+# price, beds, baths, floor area, address, and tenure from the card text.
+_EXTRACT_LISTINGS_JS = r"""() => {
+ const links = Array.from(document.querySelectorAll(
+ 'a[href*="/for-sale/details/"], a[href*="/new-homes/details/"], a[href*="/to-rent/details/"]'
+ ));
+
+ const seen = new Set();
+ const results = [];
+
+ for (const link of links) {
+ const href = link.href;
+ const match = href.match(/\/details\/(\d+)\//);
+ if (!match) continue;
+
+ const id = match[1];
+ if (seen.has(id)) continue;
+ seen.add(id);
+
+ // Walk up to the listing card container
+ let card = link;
+ for (let j = 0; j < 10; j++) {
+ card = card.parentElement;
+ if (!card) break;
+ const text = card.innerText || '';
+ if (text.includes('\u00a3') && (text.includes('bed') || text.includes('sq ft'))) {
+ break;
+ }
+ }
+ if (!card) continue;
+
+ const text = card.innerText || '';
+ const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
+
+ const priceMatch = text.match(/\u00a3([\d,]+)/);
+ const bedsMatch = text.match(/(\d+)\s*beds?/i);
+ const bathsMatch = text.match(/(\d+)\s*baths?/i);
+ const recMatch = text.match(/(\d+)\s*reception/i);
+ const areaMatch = text.match(/([\d,]+)\s*sq\s*ft/i);
+
+ let address = '';
+ for (const line of lines) {
+ if (/[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}/i.test(line) ||
+ (line.includes(',') && !line.includes('\u00a3') && !/^\d+ beds?/i.test(line))) {
+ address = line;
+ break;
+ }
+ }
+
+ let tenure = '';
+ if (/freehold/i.test(text)) tenure = 'Freehold';
+ else if (/leasehold/i.test(text)) tenure = 'Leasehold';
+
+ results.push({
+ id: id,
+ url: href.replace(window.location.origin, ''),
+ price: priceMatch ? parseInt(priceMatch[1].replace(/,/g, '')) : null,
+ beds: bedsMatch ? parseInt(bedsMatch[1]) : null,
+ baths: bathsMatch ? parseInt(bathsMatch[1]) : null,
+ receptions: recMatch ? parseInt(recMatch[1]) : null,
+ floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
+ address: address,
+ tenure: tenure,
+ });
+ }
+
+ return results;
+}"""
+
+# JavaScript to dismiss the Usercentrics cookie consent overlay (shadow DOM).
+_DISMISS_COOKIES_JS = """() => {
+ const aside = document.querySelector('#usercentrics-cmp-ui');
+ if (aside && aside.shadowRoot) {
+ const btns = aside.shadowRoot.querySelectorAll('button');
+ for (const btn of btns) {
+ if (btn.innerText.includes('Accept')) { btn.click(); return true; }
+ }
+ }
+ if (aside) { aside.remove(); return true; }
+ return false;
+}"""
+
+
+# ---------------------------------------------------------------------------
+# Browser lifecycle
+# ---------------------------------------------------------------------------
+
+
+def launch_browser():
+ """Launch Camoufox, navigate to Zoopla homepage, pass Cloudflare Turnstile,
+ and dismiss cookie consent. Returns (browser, page) tuple.
+
+ Raises TurnstileError if Cloudflare cannot be passed within 60 seconds.
+ Caller must close browser when done."""
+ from camoufox.pkgman import camoufox_path
+
+ # Verify camoufox is pre-installed — never download at runtime
+ camoufox_path(download_if_missing=False)
+
+ from camoufox.sync_api import Camoufox
+
+ log.info("Launching Camoufox browser for Zoopla...")
+ browser = Camoufox(headless=True).__enter__()
+ page = browser.new_page()
+
+ log.info("Navigating to Zoopla homepage...")
+ page.goto(f"{ZOOPLA_BASE}/", wait_until="domcontentloaded", timeout=60000)
+
+ # Wait for Cloudflare Turnstile to resolve.
+ # Try clicking the Turnstile checkbox if present (helps in some cases).
+ for i in range(20):
+ if "Just a moment" not in page.title():
+ break
+ # Attempt to click the Turnstile checkbox in the challenge iframe
+ for frame in page.frames:
+ if "challenges.cloudflare.com" in frame.url:
+ try:
+ iframe_el = page.query_selector('iframe[src*="challenges.cloudflare"]')
+ if iframe_el:
+ box = iframe_el.bounding_box()
+ if box:
+ page.mouse.click(box["x"] + 30, box["y"] + box["height"] / 2)
+ except Exception:
+ pass
+ break
+ time.sleep(3)
+ else:
+ page.close()
+ browser.close()
+ raise TurnstileError("Cloudflare Turnstile did not resolve after 60s")
+
+ log.info("Cloudflare passed — title: %s", page.title())
+ time.sleep(2)
+
+ # Dismiss cookie consent
+ page.evaluate(_DISMISS_COOKIES_JS)
+ time.sleep(1)
+
+ return browser, page
+
+
+def _ensure_not_challenged(page) -> None:
+ """Check if current page is a Cloudflare challenge and wait/raise."""
+ if "Just a moment" not in page.title():
+ return
+
+ log.warning("Cloudflare challenge detected mid-session, waiting...")
+ for i in range(20):
+ time.sleep(3)
+ if "Just a moment" not in page.title():
+ log.info("Cloudflare challenge resolved")
+ return
+
+ raise TurnstileError("Cloudflare re-challenge did not resolve")
+
+
+# ---------------------------------------------------------------------------
+# Search navigation
+# ---------------------------------------------------------------------------
+
+
+def _navigate_search(page, outcode: str, channel: str) -> bool:
+ """Navigate to search results for an outcode via the homepage search flow.
+
+ Returns True if results were found, False if no results or navigation failed.
+ Raises TurnstileError if Cloudflare blocks us."""
+ # Navigate to homepage to reset search state
+ page.goto(f"{ZOOPLA_BASE}/", wait_until="domcontentloaded", timeout=30000)
+ time.sleep(2)
+ _ensure_not_challenged(page)
+
+ # Dismiss cookie consent (may reappear after navigation)
+ page.evaluate(_DISMISS_COOKIES_JS)
+ time.sleep(1)
+
+ # Select Buy/Rent tab
+ if channel == "RENT":
+ rent_tab = page.query_selector(
+ 'button:has-text("Rent"), [role="tab"]:has-text("Rent")'
+ )
+ if rent_tab:
+ rent_tab.click()
+ time.sleep(0.5)
+
+ # Find and fill search input
+ search_input = page.query_selector(
+ 'input[name="autosuggest-input"]'
+ ) or page.query_selector('input[type="text"]')
+ if not search_input:
+ log.warning("Could not find search input on homepage")
+ return False
+
+ search_input.click()
+ time.sleep(0.3)
+ search_input.fill("")
+ search_input.type(outcode, delay=60)
+ time.sleep(2)
+
+ # Select first autocomplete suggestion
+ first_option = page.query_selector('[role="option"]')
+ if not first_option:
+ log.debug("No autocomplete suggestions for outcode %s", outcode)
+ return False
+
+ first_option.click()
+ time.sleep(0.5)
+
+ # Click search button
+ search_btn = page.query_selector('button:has-text("Search")')
+ if search_btn:
+ search_btn.click()
+ else:
+ search_input.press("Enter")
+
+ # Wait for results to load
+ time.sleep(6)
+ _ensure_not_challenged(page)
+
+ return True
+
+
+def _get_result_count(page) -> int:
+ """Extract the total results count from the page body text."""
+ try:
+ body = page.inner_text("body")
+ match = re.search(r"([\d,]+)\s+results?", body)
+ if match:
+ return int(match.group(1).replace(",", ""))
+ except Exception:
+ pass
+ return 0
+
+
+# ---------------------------------------------------------------------------
+# Extraction and pagination
+# ---------------------------------------------------------------------------
+
+
+def _extract_listings(page) -> list[dict]:
+ """Extract listing data from the current search results page DOM."""
+ try:
+ return page.evaluate(_EXTRACT_LISTINGS_JS)
+ except Exception as e:
+ log.warning("Failed to extract listings from DOM: %s", e)
+ zoopla_errors_total.labels(type="extract_failed").inc()
+ return []
+
+
+def _paginate(page, total_results: int, channel: str) -> list[dict]:
+ """Extract listings from all pages of search results.
+
+ Page 1 is already loaded. For subsequent pages, clicks the Next button
+ or navigates via URL parameter ?pn=N."""
+ all_listings = _extract_listings(page)
+ channel_label = "buy" if channel == "BUY" else "rent"
+ zoopla_pages_scraped.labels(channel=channel_label).inc()
+
+ if not all_listings or total_results <= len(all_listings):
+ return all_listings
+
+ seen_ids = {listing["id"] for listing in all_listings}
+ current_url = page.url
+ page_num = 2
+
+ while len(all_listings) < total_results and page_num <= MAX_PAGES_PER_OUTCODE:
+ time.sleep(DELAY_BETWEEN_PAGES)
+
+ # Try navigating via URL parameter
+ if "?" in current_url:
+ next_url = re.sub(r"[?&]pn=\d+", "", current_url)
+ separator = "&" if "?" in next_url else "?"
+ next_url = f"{next_url}{separator}pn={page_num}"
+ else:
+ next_url = f"{current_url}?pn={page_num}"
+
+ try:
+ page.goto(next_url, wait_until="domcontentloaded", timeout=30000)
+ time.sleep(4)
+ _ensure_not_challenged(page)
+ except TurnstileError:
+ raise
+ except Exception as e:
+ log.debug("Pagination navigation failed at page %d: %s", page_num, e)
+ break
+
+ page_listings = _extract_listings(page)
+ if not page_listings:
+ break
+
+ # Deduplicate within this outcode
+ new_count = 0
+ for listing in page_listings:
+ if listing["id"] not in seen_ids:
+ seen_ids.add(listing["id"])
+ all_listings.append(listing)
+ new_count += 1
+
+ zoopla_pages_scraped.labels(channel=channel_label).inc()
+
+ if new_count == 0:
+ break # No new listings on this page
+
+ page_num += 1
+
+ return all_listings
+
+
+# ---------------------------------------------------------------------------
+# Property transformation
+# ---------------------------------------------------------------------------
+
+
+def _extract_postcode(text: str) -> str | None:
+ """Extract a full UK postcode from text like 'Dollar Bay Place, Canary Wharf E14 9SS'."""
+ match = re.search(r"([A-Z]{1,2}\d[A-Z0-9]?\s*\d[A-Z]{2})", text, re.IGNORECASE)
+ if match:
+ return match.group(1).upper().strip()
+ return None
+
+
+def _extract_outcode(text: str) -> str | None:
+ """Extract a UK outcode from address text like 'Whitechapel Road, London E1'."""
+ # Look for outcode at end of string or after last comma
+ match = re.search(r"\b([A-Z]{1,2}\d[A-Z0-9]?)\s*$", text.strip(), re.IGNORECASE)
+ if match:
+ return match.group(1).upper()
+ # Try after comma
+ parts = text.split(",")
+ if len(parts) > 1:
+ last = parts[-1].strip()
+ match = re.match(r"^([A-Z]{1,2}\d[A-Z0-9]?)$", last, re.IGNORECASE)
+ if match:
+ return match.group(1).upper()
+ return None
+
+
+def _map_property_type(raw_type: str | None) -> str:
+ """Map Zoopla property type text to canonical type."""
+ if not raw_type:
+ return "Other"
+ canonical = PROPERTY_TYPE_MAP.get(raw_type)
+ if canonical:
+ return canonical
+ lower = raw_type.lower()
+ if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower:
+ return "Flats/Maisonettes"
+ if "detached" in lower and "semi" not in lower:
+ return "Detached"
+ if "semi" in lower:
+ return "Semi-Detached"
+ if "terrace" in lower or "mews" in lower:
+ return "Terraced"
+ if "house" in lower:
+ return "Detached"
+ return "Other"
+
+
+def transform_property(
+ raw: dict,
+ channel: str,
+ pc_index: PostcodeSpatialIndex,
+ pc_coords: dict[str, tuple[float, float]],
+) -> dict | None:
+ """Transform a raw Zoopla listing dict into the standard output schema.
+
+ Zoopla search cards do not include coordinates, so we resolve lat/lng
+ from postcodes extracted from the address text."""
+ price = raw.get("price")
+ if not price:
+ return None
+
+ address = raw.get("address", "")
+
+ # Resolve postcode and coordinates from address
+ postcode = _extract_postcode(address)
+ lat = lng = None
+
+ if postcode:
+ coords = pc_coords.get(postcode)
+ if coords:
+ lat, lng = coords
+
+ if lat is None:
+ # Try outcode-level fallback
+ outcode = _extract_outcode(address)
+ if outcode:
+ prefix = outcode + " "
+ for pcd, coords in pc_coords.items():
+ if pcd.startswith(prefix):
+ postcode = pcd
+ lat, lng = coords
+ break
+
+ if lat is None or lng is None or not postcode:
+ return None
+
+ # Validate coordinates are in England
+ if not (49 <= lat <= 56 and -7 <= lng <= 2):
+ return None
+
+ bedrooms = raw.get("beds") or 0
+ bathrooms = raw.get("baths") or 0
+ receptions = raw.get("receptions") or 0
+
+ # Floor area: convert sq ft to sq m
+ floor_area_sqm = None
+ sqft = raw.get("floor_area_sqft")
+ if sqft:
+ floor_area_sqm = round(sqft * 0.092903, 1)
+
+ listing_id = raw.get("id", "")
+ listing_url = raw.get("url", "")
+ if listing_url and not listing_url.startswith("http"):
+ listing_url = ZOOPLA_BASE + listing_url
+
+ return {
+ "id": f"zp_{listing_id}",
+ "Bedrooms": bedrooms,
+ "Bathrooms": bathrooms,
+ "Number of bedrooms & living rooms": bedrooms + receptions,
+ "lon": lng,
+ "lat": lat,
+ "Postcode": postcode,
+ "Address per Property Register": address,
+ "Leasehold/Freehold": raw.get("tenure") or None,
+ "Property type": "Other", # Not reliably extractable from Zoopla search cards
+ "Property sub-type": "",
+ "price": int(price),
+ "price_frequency": "" if channel == "BUY" else "monthly",
+ "Price qualifier": "",
+ "Total floor area (sqm)": floor_area_sqm,
+ "Listing URL": listing_url,
+ "Listing features": [],
+ "first_visible_date": "",
+ }
+
+
+# ---------------------------------------------------------------------------
+# Top-level search function (called by scraper.py)
+# ---------------------------------------------------------------------------
+
+
+def search_outcode(
+ page,
+ outcode: str,
+ channel: str,
+ pc_index: PostcodeSpatialIndex,
+ pc_coords: dict[str, tuple[float, float]],
+) -> list[dict]:
+ """Search Zoopla for properties in one outcode.
+
+ Takes a live Camoufox Page (from launch_browser). Navigates through the
+ search flow, extracts listings from rendered DOM, and transforms to the
+ standard output schema.
+
+ Raises TurnstileError if Cloudflare blocks us mid-session.
+ """
+ if not _navigate_search(page, outcode, channel):
+ return []
+
+ total_results = _get_result_count(page)
+ if total_results == 0:
+ return []
+
+ raw_listings = _paginate(page, total_results, channel)
+ if not raw_listings:
+ return []
+
+ channel_label = "buy" if channel == "BUY" else "rent"
+ properties = []
+ for raw in raw_listings:
+ transformed = transform_property(raw, channel, pc_index, pc_coords)
+ if transformed:
+ properties.append(transformed)
+ zoopla_properties_scraped.labels(channel=channel_label).inc()
+
+ return properties
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5aedbd5..1c2f0f4 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -9,7 +9,6 @@ import Header, { type Page } from './components/ui/Header';
import AuthModal from './components/ui/AuthModal';
import SaveSearchModal from './components/ui/SaveSearchModal';
import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
-import VerificationBanner from './components/ui/VerificationBanner';
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
import { fetchWithRetry, apiUrl } from './lib/api';
import { trackEvent } from './lib/analytics';
@@ -118,15 +117,12 @@ export default function App() {
loginWithOAuth,
logout,
requestPasswordReset,
- requestVerification,
refreshAuth,
clearError,
} = useAuth();
const [showAuthModal, setShowAuthModal] = useState(false);
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
const [showLicenseSuccess, setShowLicenseSuccess] = useState(false);
- const [verificationDismissed, setVerificationDismissed] = useState(false);
-
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('license_success') === '1') {
@@ -304,19 +300,11 @@ export default function App() {
onLogout={logout}
isMobile={isMobile}
/>
- {user && !user.verified && !verificationDismissed && isAuthRequiredPage && (
- setVerificationDismissed(true)}
- />
- )}
{activePage === 'home' ? (
navigateTo('dashboard')}
onOpenPricing={() => navigateTo('pricing')}
theme={theme}
- features={features}
hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
/>
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
@@ -340,6 +328,7 @@ export default function App() {
searchesLoading={savedSearches.loading}
onDeleteSearch={savedSearches.deleteSearch}
onUpdateSearchNotes={savedSearches.updateSearchNotes}
+ onUpdateSearchName={savedSearches.updateSearchName}
onOpenSearch={(params) => {
window.location.href = `/dashboard?${params}`;
}}
@@ -354,11 +343,7 @@ export default function App() {
) : activePage === 'invites' && user ? (
) : activePage === 'account' && user ? (
-
+
) : activePage === 'invite' && inviteCode ? (
)}
{showAuthModal && (
diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx
index 63b0f8d..39847ee 100644
--- a/frontend/src/components/account/AccountPage.tsx
+++ b/frontend/src/components/account/AccountPage.tsx
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties';
-import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
+import { apiUrl, authHeaders, assertOk, shortenUrl, prewarmScreenshot } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
import { formatRelativeTime, formatNumber } from '../../lib/format';
import { summarizeParams } from '../../lib/url-state';
@@ -71,13 +71,7 @@ function DeleteDialog({
);
}
-function NotesInput({
- value,
- onSave,
-}: {
- value: string;
- onSave: (notes: string) => void;
-}) {
+function NotesInput({ value, onSave }: { value: string; onSave: (notes: string) => void }) {
const [text, setText] = useState(value);
const textareaRef = useRef(null);
const timerRef = useRef | null>(null);
@@ -146,17 +140,72 @@ function formatPropertyDetails(data: SavedPropertyData): string {
return parts.join(' · ');
}
+function EditableName({ value, onSave }: { value: string; onSave: (name: string) => void }) {
+ const [editing, setEditing] = useState(false);
+ const [text, setText] = useState(value);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ setText(value);
+ }, [value]);
+
+ useEffect(() => {
+ if (editing) {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }
+ }, [editing]);
+
+ const commit = () => {
+ setEditing(false);
+ const trimmed = text.trim();
+ if (trimmed && trimmed !== value) onSave(trimmed);
+ else setText(value);
+ };
+
+ if (editing) {
+ return (
+ setText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') commit();
+ if (e.key === 'Escape') {
+ setText(value);
+ setEditing(false);
+ }
+ }}
+ onBlur={commit}
+ className="w-full font-medium text-navy-950 dark:text-warm-100 bg-warm-50 dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded px-1.5 py-0.5 text-sm focus:outline-none focus:ring-1 focus:ring-teal-400"
+ />
+ );
+ }
+
+ return (
+ setEditing(true)}
+ className="font-medium text-navy-950 dark:text-warm-100 truncate cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-transparent hover:border-warm-400 dark:hover:border-warm-500"
+ title="Click to rename"
+ >
+ {value}
+
+ );
+}
+
function SavedSearchesTab({
searches,
loading,
onDelete,
onUpdateNotes,
+ onUpdateName,
onOpen,
}: {
searches: SavedSearch[];
loading: boolean;
onDelete: (id: string) => Promise;
onUpdateNotes: (id: string, notes: string) => void;
+ onUpdateName: (id: string, name: string) => void;
onOpen: (params: string) => void;
}) {
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
@@ -178,6 +227,7 @@ function SavedSearchesTab({
const handleShare = useCallback(
async (params: string, id: string) => {
+ prewarmScreenshot(params);
setSharingId(id);
try {
const shortUrl = await shortenUrl(params);
@@ -207,7 +257,7 @@ function SavedSearchesTab({
No saved searches yet
- Save your dashboard filters and view to quickly return to them later.
+ Save your filters and map view so you can pick up exactly where you left off.
);
@@ -219,7 +269,7 @@ function SavedSearchesTab({
{searches.map((search) => (
{search.screenshotUrl ? (
)}
-
-
- {search.name}
-
+
+
+ onUpdateName(search.id, name)}
+ />
+
{formatRelativeTime(search.created)}
@@ -244,14 +297,14 @@ function SavedSearchesTab({
{summarizeParams(search.params)}
-
+
onUpdateNotes(search.id, notes)}
/>
-
+
onOpen(search.params)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
@@ -333,7 +386,7 @@ function SavedPropertiesTab({
No saved properties yet
- Click the bookmark icon on any property in the dashboard to save it here.
+ Bookmark properties as you explore and build your shortlist without losing track.
);
@@ -348,7 +401,7 @@ function SavedPropertiesTab({
return (
@@ -366,38 +419,37 @@ function SavedPropertiesTab({
{formatRelativeTime(prop.created)}
-
-
onUpdateNotes(prop.id, notes)}
- />
+
+ onUpdateNotes(prop.id, notes)} />
-
-
onOpen(prop.postcode)}
- className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
- >
- Open postcode
-
-
setDeleteConfirmId(prop.id)}
- className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
- title="Delete"
- >
-
-
+
+
+ onOpen(prop.postcode)}
+ className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
+ >
+ Open postcode
+
+ setDeleteConfirmId(prop.id)}
+ className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
+ title="Delete"
+ >
+
+
+
+ {prop.data.listingUrl && (
+
+ View listing →
+
+ )}
- {prop.data.listingUrl && (
-
- View listing →
-
- )}
);
})}
@@ -420,6 +472,7 @@ export function SavedPage({
searchesLoading,
onDeleteSearch,
onUpdateSearchNotes,
+ onUpdateSearchName,
onOpenSearch,
savedProperties,
propertiesLoading,
@@ -431,6 +484,7 @@ export function SavedPage({
searchesLoading: boolean;
onDeleteSearch: (id: string) => Promise;
onUpdateSearchNotes: (id: string, notes: string) => void;
+ onUpdateSearchName: (id: string, name: string) => void;
onOpenSearch: (params: string) => void;
savedProperties: SavedProperty[];
propertiesLoading: boolean;
@@ -476,6 +530,7 @@ export function SavedPage({
loading={searchesLoading}
onDelete={onDeleteSearch}
onUpdateNotes={onUpdateSearchNotes}
+ onUpdateName={onUpdateSearchName}
onOpen={onOpenSearch}
/>
) : (
@@ -755,18 +810,13 @@ export function InvitesPage({ user }: { user: AuthUser }) {
export default function AccountPage({
user,
onRefreshAuth,
- onRequestVerification,
}: {
user: AuthUser;
onRefreshAuth: () => Promise;
- onRequestVerification: (email: string) => Promise;
}) {
const [newsletterSaving, setNewsletterSaving] = useState(false);
const [newsletterError, setNewsletterError] = useState(null);
- const [verificationSending, setVerificationSending] = useState(false);
- const [verificationSent, setVerificationSent] = useState(false);
-
const badgeColor =
user.subscription === 'licensed'
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
@@ -782,38 +832,6 @@ export default function AccountPage({
Email
{user.email}
-
- {!user.verified && (
- {
- setVerificationSending(true);
- try {
- await onRequestVerification(user.email);
- setVerificationSent(true);
- setTimeout(() => setVerificationSent(false), 3000);
- } catch {
- // Error handled by hook
- } finally {
- setVerificationSending(false);
- }
- }}
- disabled={verificationSending || verificationSent}
- className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 disabled:opacity-50 flex items-center gap-1"
- >
- {verificationSending && }
- {verificationSent ? 'Sent!' : 'Resend verification'}
-
- )}
-
- {user.verified ? 'Verified' : 'Unverified'}
-
-
{/* Subscription */}
@@ -823,7 +841,7 @@ export default function AccountPage({
- {user.subscription === 'licensed' ? 'Licensed' : 'Free'}
+ {user.subscription === 'licensed' ? 'Full Access' : 'Inner London'}
diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx
index 53abc75..1207add 100644
--- a/frontend/src/components/home/HomePage.tsx
+++ b/frontend/src/components/home/HomePage.tsx
@@ -1,25 +1,20 @@
import { useState, useEffect, useRef } from 'react';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
-import ScrollStory from './ScrollStory';
import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue';
-import { ChevronIcon } from '../ui/icons/ChevronIcon';
import { LogoIcon } from '../ui/icons/LogoIcon';
import { trackEvent } from '../../lib/analytics';
-import type { FeatureMeta } from '../../types';
export default function HomePage({
onOpenDashboard,
onOpenPricing: _onOpenPricing,
theme = 'light',
- features = [],
hidePricing: _hidePricing,
}: {
onOpenDashboard: () => void;
onOpenPricing: () => void;
theme?: 'light' | 'dark';
- features?: FeatureMeta[];
hidePricing?: boolean;
}) {
const [statsActive, setStatsActive] = useState(false);
@@ -34,7 +29,7 @@ export default function HomePage({
// Scroll depth tracking
const scrolledSections = useRef(new Set
());
useEffect(() => {
- const ids = ['how-it-works', 'demo'];
+ const ids = ['how-it-works'];
const observers: IntersectionObserver[] = [];
ids.forEach((id) => {
const el = document.getElementById(id);
@@ -142,35 +137,6 @@ export default function HomePage({
-
{
- trackEvent('CTA Click', { location: 'hero', label: 'see_it_in_action' });
- const target = document.getElementById('demo');
- if (!target) return;
- const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
- if (!scroller) return;
- const start = scroller.scrollTop;
- const end =
- start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
- const distance = end - start;
- const duration = 1200;
- let startTime: number;
- const step = (time: number) => {
- if (!startTime) startTime = time;
- const t = Math.min((time - startTime) / duration, 1);
- const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
- scroller.scrollTop = start + distance * ease;
- if (t < 1) requestAnimationFrame(step);
- };
- requestAnimationFrame(step);
- }}
- className="flex flex-col items-center pb-8 mt-10 md:mt-0 animate-[bounce_3s_ease-in-out_infinite] cursor-pointer"
- >
-
- See It in Action
-
-
-
@@ -181,13 +147,13 @@ export default function HomePage({
- Listings show what's available, not what's possible — fragments
- without context. Traditional tools force you to begin with a location, separating area
- insight from property detail. You search, cross-reference, and repeat per location.
+ On Rightmove, you pick an area first, then hope it's good. You end up
+ cross-referencing crime stats, school reports, and broadband checkers across a dozen
+ tabs, one postcode at a time.
- We take a different approach. Start with what matters to you, and the right places
- reveal themselves. No context lost. No property missed.
+ We flip that. Tell us what you need (budget, commute, schools, safety) and we show you
+ every area in England that qualifies. No guesswork. No wasted viewings.
@@ -286,15 +252,6 @@ export default function HomePage({
- {/* Scrollytelling: Problem + Solution + Demo map */}
-
- See It in Action
-
-
-
{/* The real cost CTA */}
@@ -302,7 +259,7 @@ export default function HomePage({
Make your biggest investment your smartest move.
- This deserves proper tools behind it — don't leave it to luck.
+ This deserves proper tools behind it, don't leave it to luck.
{
diff --git a/frontend/src/components/home/ScrollStory.tsx b/frontend/src/components/home/ScrollStory.tsx
deleted file mode 100644
index 56e5471..0000000
--- a/frontend/src/components/home/ScrollStory.tsx
+++ /dev/null
@@ -1,466 +0,0 @@
-import { useState, useEffect, useRef, useMemo } from 'react';
-import MapComponent from '../map/Map';
-import { apiUrl, assertOk, authHeaders, isAbortError, logNonAbortError } from '../../lib/api';
-import { formatValue } from '../../lib/format';
-import { zoomToResolution } from '../../lib/map-utils';
-import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
-import { gradientToCss } from '../../lib/utils';
-import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
-import type { FeatureMeta, HexagonData } from '../../types';
-
-const DEMO_VIEW = { longitude: -0.12, latitude: 51.51, zoom: 5.5, pitch: 0 };
-const DEMO_FEATURE_NAMES = [
- 'Estimated current price',
- 'Good+ primary schools within 5km',
- 'Number of restaurants within 2km',
-];
-const noop = () => {};
-
-// Filter fractions per stage: featureName -> [minFrac, maxFrac]
-// 0 = feature.min, 1 = feature.max
-interface StageDef {
- filters: Record;
- travel?: { mode: string; slug: string; min: number; max: number };
-}
-
-const STAGES: StageDef[] = [
- // 0: No filters — the problem
- { filters: {} },
- // 1: Price filter
- {
- filters: { 'Estimated current price': [0, 0.4] },
- },
- // 2: Price + schools
- {
- filters: {
- 'Estimated current price': [0, 0.4],
- 'Good+ primary schools within 5km': [0.3, 1],
- },
- },
- // 3: Price + schools + restaurants
- {
- filters: {
- 'Estimated current price': [0, 0.4],
- 'Good+ primary schools within 5km': [0.3, 1],
- 'Number of restaurants within 2km': [0.15, 1],
- },
- },
- // 4: Price + schools + restaurants + commute to Manchester
- {
- filters: {
- 'Estimated current price': [0, 0.4],
- 'Good+ primary schools within 5km': [0.3, 1],
- 'Number of restaurants within 2km': [0.15, 1],
- },
- travel: { mode: 'transit', slug: 'manchester', min: 0, max: 45 },
- },
- // 5: Summary — same filters
- {
- filters: {
- 'Estimated current price': [0, 0.4],
- 'Good+ primary schools within 5km': [0.3, 1],
- 'Number of restaurants within 2km': [0.15, 1],
- },
- travel: { mode: 'transit', slug: 'manchester', min: 0, max: 45 },
- },
-];
-
-const STEPS: { heading: string | null; body: React.ReactNode }[] = [
- {
- heading: null,
- body: (
- <>
-
- Let's look at an example:
-
-
- You're about to spend{' '}
- up to £500k on a home.
-
- >
- ),
- },
- {
- heading: null,
- body: (
- <>
-
-
- 1
-
-
- Set your must-haves
-
-
-
- Say you want a home{' '}
- under £500k …
-
- >
- ),
- },
- {
- heading: null,
- body: (
-
- …with{' '}
- good primary schools {' '}
- nearby…
-
- ),
- },
- {
- heading: null,
- body: (
-
- …and{' '}
-
- restaurants within walking distance
-
- …
-
- ),
- },
- {
- heading: null,
- body: (
-
- …all within{' '}
- 45 minutes of Manchester by
- public transport.
-
- ),
- },
- {
- heading: null,
- body: (
- <>
-
- No area chosen. No listings browsed. Yet you already know exactly where your needs are
- met.
-
-
- That's just 4 filters. We've built{' '}
- 56 — covering commute
- times, crime, broadband, noise, schools, amenities, and more.
-
- >
- ),
- },
-];
-
-interface ScrollStoryProps {
- features: FeatureMeta[];
- theme: 'light' | 'dark';
-}
-
-export default function ScrollStory({ features, theme }: ScrollStoryProps) {
- const [stage, setStage] = useState(0);
- const [hexData, setHexData] = useState([]);
- const [loading, setLoading] = useState(true);
- const abortRef = useRef();
-
- const demoFeatures = useMemo(
- () =>
- DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter(
- Boolean
- ) as FeatureMeta[],
- [features]
- );
-
- // Compute actual filter values from stage fractions + feature metadata
- const stageFilters = useMemo(() => {
- const stageDef = STAGES[stage];
- const result: Record = {};
- for (const [name, [minFrac, maxFrac]] of Object.entries(stageDef.filters)) {
- const meta = demoFeatures.find((f) => f.name === name);
- if (meta?.min != null && meta?.max != null) {
- const range = meta.max - meta.min;
- result[name] = [meta.min + range * minFrac, meta.min + range * maxFrac];
- }
- }
- return result;
- }, [stage, demoFeatures]);
-
- const demoView = useMemo(() => DEMO_VIEW, []);
-
- // Derive H3 resolution from current zoom (discrete — only changes at thresholds)
- const resolution = zoomToResolution(demoView.zoom);
-
- // Compute bounds string from current view, rounded to 0.5° for stability
- const demoBounds = useMemo(() => {
- const { longitude, latitude, zoom } = demoView;
- const scale = Math.pow(2, zoom);
- const degreesPerPixelLng = 360 / (512 * scale);
- const halfW = (1200 / 2) * degreesPerPixelLng * 1.3;
- const latRad = (latitude * Math.PI) / 180;
- const mercY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
- const worldSize = 512 * scale;
- const halfH = (800 / 2) * 1.3;
- const topY = mercY * worldSize - halfH;
- const botY = mercY * worldSize + halfH;
- const toLat = (py: number) => {
- const my = Math.max(0.001, Math.min(0.999, py / worldSize));
- return (Math.atan(Math.sinh(Math.PI * (1 - 2 * my))) * 180) / Math.PI;
- };
- const snap = (v: number) => Math.round(v * 2) / 2;
- const south = snap(Math.max(-85, toLat(botY)));
- const west = snap(Math.max(-180, longitude - halfW));
- const north = snap(Math.min(85, toLat(topY)));
- const east = snap(Math.min(180, longitude + halfW));
- return `${south},${west},${north},${east}`;
- }, [demoView]);
-
- // Fetch hex data when stage filters change
- useEffect(() => {
- if (features.length === 0) return;
-
- // Clear stale data and show loading spinner immediately
- setLoading(true);
- setHexData([]);
-
- const params = new URLSearchParams({
- resolution: String(resolution),
- bounds: demoBounds,
- });
- const filterParts: string[] = [];
- for (const [name, [min, max]] of Object.entries(stageFilters)) {
- filterParts.push(`${name}:${min}:${max}`);
- }
- if (filterParts.length > 0) params.set('filters', filterParts.join(','));
-
- const stageDef = STAGES[stage];
- if (stageDef.travel) {
- const { mode, slug, min, max } = stageDef.travel;
- params.set('travel', `${mode}:${slug}:${min}:${max}`);
- }
-
- const controller = new AbortController();
- abortRef.current?.abort();
- abortRef.current = controller;
- fetch(apiUrl('hexagons', params), authHeaders({ signal: controller.signal }))
- .then((res) => {
- assertOk(res, 'hexagons');
- return res.json();
- })
- .then((data: { features: HexagonData[] }) => {
- setHexData(data.features);
- setLoading(false);
- })
- .catch((err) => {
- if (!isAbortError(err)) {
- logNonAbortError('Failed to fetch story hexagons', err);
- setLoading(false);
- }
- });
-
- return () => controller.abort();
- }, [features, stageFilters, stage, resolution, demoBounds]);
-
- const isLastStage = stage === STEPS.length - 1;
-
- return (
-
- {/* Map background */}
-
-
-
-
- {/* Interaction blocker */}
-
-
- {/* Loading */}
- {loading && (
-
-
-
- )}
-
- {/* Filter indicators — top left */}
-
-
-
- Filters
-
- {demoFeatures.map((feature) => {
- const filterVal = stageFilters[feature.name];
- const isActive = !!filterVal;
- const min = feature.min ?? 0;
- const max = feature.max ?? 1;
- const range = max - min || 1;
- const leftPct = filterVal ? ((filterVal[0] - min) / range) * 100 : 0;
- const widthPct = filterVal ? ((filterVal[1] - filterVal[0]) / range) * 100 : 100;
- return (
-
-
-
- {feature.name}
-
- {isActive && filterVal && (
-
- {formatValue(filterVal[0], feature)}–
- {formatValue(filterVal[1], feature)}
-
- )}
-
-
-
- );
- })}
- {/* Travel time indicator */}
-
-
-
- Commute to Manchester
-
- {STAGES[stage].travel && (
-
- 0–45 min
-
- )}
-
-
-
-
-
-
- {/* Density legend — top right */}
-
-
-
- Colour
-
-
- Number of properties
-
-
-
- Fewer
- More
-
-
-
-
- {/* Card stack overlay — bottom on mobile, right-center on desktop */}
-
-
- {STEPS.map((step, i) => (
-
-
- {step.heading && (
-
- {step.heading}
-
- )}
-
{step.body}
-
- {/* Navigation */}
-
- {/* Step dots */}
-
- {STEPS.map((_, dotIdx) => (
- setStage(dotIdx)}
- className={`w-2 h-2 rounded-full transition-all duration-300 ${
- dotIdx === stage
- ? 'bg-teal-600 dark:bg-teal-400 w-4'
- : dotIdx < stage
- ? 'bg-teal-600/40 dark:bg-teal-400/40'
- : 'bg-warm-300 dark:bg-warm-600'
- }`}
- aria-label={`Go to step ${dotIdx + 1}`}
- />
- ))}
-
-
- {/* Prev / Next / CTA buttons */}
-
- {stage > 0 && (
-
setStage(stage - 1)}
- className="inline-flex items-center gap-1 px-4 py-2 rounded-lg border border-warm-200 dark:border-warm-700 text-warm-600 dark:text-warm-300 font-semibold text-sm hover:bg-warm-50 dark:hover:bg-warm-800 transition-colors"
- >
- ← Back
-
- )}
- {isLastStage ? (
-
- Start exploring →
-
- ) : (
-
setStage(stage + 1)}
- className="inline-flex items-center gap-1 px-4 py-2 rounded-lg bg-teal-600 text-white font-semibold text-sm hover:bg-teal-700 transition-colors"
- >
- Next →
-
- )}
-
-
-
-
- ))}
-
-
-
- );
-}
diff --git a/frontend/src/components/invite/InvitePage.tsx b/frontend/src/components/invite/InvitePage.tsx
index 60446a4..8fb68e1 100644
--- a/frontend/src/components/invite/InvitePage.tsx
+++ b/frontend/src/components/invite/InvitePage.tsx
@@ -94,10 +94,14 @@ export default function InvitePage({
const isDark = theme === 'dark';
- // Signal screenshot readiness once loading completes
+ // Signal screenshot readiness once loading completes and a frame has painted
useEffect(() => {
if (screenshotMode && !loading) {
- window.__screenshot_ready = true;
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ window.__screenshot_ready = true;
+ });
+ });
}
}, [screenshotMode, loading]);
@@ -313,7 +317,7 @@ export default function InvitePage({
{redeeming && }
{isAdminInvite
diff --git a/frontend/src/components/learn/LearnPage.tsx b/frontend/src/components/learn/LearnPage.tsx
index 585c222..d186f80 100644
--- a/frontend/src/components/learn/LearnPage.tsx
+++ b/frontend/src/components/learn/LearnPage.tsx
@@ -142,19 +142,19 @@ const FAQ_SECTIONS: FAQSection[] = [
title: 'Finding Your Area',
items: [
{
- question: "I don't even know which areas to look at \u2014 can this help with that?",
+ question: "I don't even know which areas to look at. Can this help?",
answer:
- "That's exactly what it's for. Set your filters (budget, commute time, low crime, good schools \u2014 whatever matters) and the map lights up to show you where ticks every box. No more Googling \"best areas to live near Manchester\" at 1am.",
+ 'That\'s exactly what it\'s for. Set your filters (budget, commute time, low crime, good schools, whatever matters) and the map lights up to show you where ticks every box. No more Googling "best areas to live near Manchester" at 1am.',
},
{
- question: "I'm moving somewhere I've never been \u2014 how do I even start?",
+ question: "I'm moving somewhere I've never been. How do I even start?",
answer:
- "Set your filters for what matters and the map instantly highlights the areas that qualify. You go from \"I don't know a single street\" to a shortlist in minutes \u2014 it's like having a local's knowledge of every neighbourhood in England.",
+ "Set your filters for what matters and the map instantly highlights the areas that qualify. You go from \"I don't know a single street\" to a shortlist in minutes. It's like having a local's knowledge of every neighbourhood in England.",
},
{
question: 'How do I find areas that tick all my boxes at once?',
answer:
- 'Stack multiple filters \u2014 say, crime below average, good schools, and commute under 40 minutes \u2014 then colour the map by price to spot the affordable sweet spots. The map updates live as you drag sliders, so you can watch neighbourhoods light up or drop off in real time.',
+ 'Stack multiple filters (crime below average, good schools, commute under 40 minutes) then colour the map by price to spot the affordable sweet spots. The map updates live as you drag sliders, so you can watch neighbourhoods light up or drop off in real time.',
},
],
},
@@ -164,7 +164,7 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: 'Can I see how long my commute would actually be from different areas?',
answer:
- "Set your workplace as a destination and we'll colour every postcode by journey time \u2014 by car, bike, or public transport. Filter to your max commute and the rest disappears, so you're only looking at areas that actually work.",
+ "Set your workplace as a destination and we'll colour every postcode by journey time, whether that's by car, bike, or public transport. Filter to your max commute and the rest disappears.",
},
{
question: 'How is that better than checking Google Maps?',
@@ -179,12 +179,12 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: 'How do I find areas where I get the most space for my money?',
answer:
- "Filter by price per sqm \u2014 you'll instantly see which postcodes give you the most square footage per pound. Pair it with the energy rating filter to avoid cheap-but-freezing money pits.",
+ "Filter by price per sqm and you'll instantly see which postcodes give you the most square footage per pound. Pair it with the energy rating filter to avoid cheap-but-freezing money pits.",
},
{
question: "How do I make sure a cheap area isn't cheap for a reason?",
answer:
- "Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable AND scores well on the stuff that matters, that's your hidden gem \u2014 not just a cheap postcode with a catch.",
+ "Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable AND scores well on the stuff that matters, that's your hidden gem, not just a cheap postcode with a catch.",
},
],
},
@@ -194,12 +194,13 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: 'How can I check if an area is safe before I move there?',
answer:
- "We overlay real police-recorded crime data \u2014 broken down by type \u2014 onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers, so you're not relying on gut feeling.",
+ 'We overlay real police-recorded crime data, broken down by type, onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers.',
},
{
- question: 'I keep finding flats that look great online, then the area turns out to be grim.',
+ question:
+ 'I keep finding flats that look great online, then the area turns out to be grim.',
answer:
- "That's why we built this. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map \u2014 so you know what a neighbourhood is actually like before you waste a Saturday viewing.",
+ "That's why we built this. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you waste a Saturday viewing.",
},
],
},
@@ -209,7 +210,7 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: 'Can I find areas with good schools AND low crime in one search?',
answer:
- 'Yes \u2014 stack filters for Ofsted ratings, crime rates, parks, and whatever else matters to your family, then watch the map highlight only the areas that tick every box. No more cross-referencing five different websites with a spreadsheet.',
+ 'Absolutely. Stack filters for Ofsted ratings, crime rates, parks, and whatever else matters to your family, then watch the map highlight only the areas that tick every box. No more cross-referencing five different websites with a spreadsheet.',
},
{
question: 'How do I know if a neighbourhood has parks and playgrounds nearby?',
@@ -222,9 +223,9 @@ const FAQ_SECTIONS: FAQSection[] = [
title: 'Environment & Quality of Life',
items: [
{
- question: 'Can I find energy-efficient homes that aren\'t on a noisy road?',
+ question: "Can I find energy-efficient homes that aren't on a noisy road?",
answer:
- 'Filter by EPC rating (A\u2013C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
+ 'Filter by EPC rating (A to C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
},
{
question: 'Does it show flood or subsidence risk?',
@@ -242,9 +243,9 @@ const FAQ_SECTIONS: FAQSection[] = [
title: 'Why Perfect Postcode',
items: [
{
- question: 'I already use Rightmove \u2014 what does this add?',
+ question: 'I already use Rightmove. What does this add?',
answer:
- "Rightmove shows you houses. We show you areas. You'll see 56 layers of data \u2014 crime rates, school ratings, broadband speeds, noise levels, deprivation scores \u2014 all on one map, so you can judge a neighbourhood before you even look at listings.",
+ "Rightmove shows you houses. We show you areas. You'll see 56 layers of data (crime rates, school ratings, broadband speeds, noise levels, deprivation scores) all on one map, so you can judge a neighbourhood before you even look at listings.",
},
{
question: "Can't I just research all this myself for free?",
@@ -254,7 +255,7 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: 'Where does the data actually come from?',
answer:
- "Every dataset comes from official UK government sources \u2014 Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don't scrape estate agents or make anything up \u2014 you can verify any record against the original source.",
+ "Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don't scrape estate agents or make anything up. You can verify any record against the original source.",
},
],
},
@@ -264,7 +265,7 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: 'Is it really worth paying for a property search tool?',
answer:
- "You're making a decision worth \u00a3200k\u2013\u00a3500k or more. Even spotting one red flag \u2014 a noisy road, poor broadband, rising crime \u2014 that changes your mind could save you years of regret. This costs less than a single viewing trip in petrol.",
+ "You're making a decision worth \u00a3200k to \u00a3500k or more. Even spotting one red flag (a noisy road, poor broadband, rising crime) that changes your mind could save you years of regret. This costs less than a single viewing trip in petrol.",
},
{
question: "Is this another subscription that'll drain my account?",
@@ -274,12 +275,12 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: 'What can I access on the free tier?',
answer:
- 'Free users can explore all features within inner London (roughly zones 1\u20132). To access data for the rest of England, you need lifetime access.',
+ 'Free users can explore all features within inner London (roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.',
},
{
question: 'Can I get a refund?',
answer:
- 'Yes \u2014 we offer a 30-day money-back guarantee. If you\u2019re not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.',
+ 'Absolutely. We offer a 30-day money-back guarantee. If you\u2019re not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.',
},
],
},
@@ -289,12 +290,12 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: 'How do I use the AI filter instead of adding filters one by one?',
answer:
- 'Type what you want in plain English \u2014 something like "quiet area near good schools with fast broadband under \u00a3400k" \u2014 and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.',
+ 'Type what you want in plain English, something like "quiet area near good schools with fast broadband under \u00a3400k", and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.',
},
{
question: 'Can I save a search and come back to it later?',
answer:
- 'Hit the save button and everything is captured \u2014 your filters, zoom level, and which data layer you\u2019re colouring by. Pick up exactly where you left off or share the link with your partner.',
+ 'Hit the save button and everything is captured: your filters, zoom level, and which data layer you\u2019re colouring by. Pick up exactly where you left off or share the link with your partner.',
},
{
question: "Can I export the data I'm looking at?",
@@ -484,7 +485,7 @@ export default function LearnPage() {
) : tab === 'faq' ? (
- Whether you're buying, renting, or just exploring — here's how Perfect
+ Whether you're buying, renting, or just exploring, here's how Perfect
Postcode helps you find the right area.
diff --git a/frontend/src/components/map/AiFilterInput.tsx b/frontend/src/components/map/AiFilterInput.tsx
index 550fb54..29de7a8 100644
--- a/frontend/src/components/map/AiFilterInput.tsx
+++ b/frontend/src/components/map/AiFilterInput.tsx
@@ -127,7 +127,7 @@ export default memo(function AiFilterInput({
AI Search
- — describe what you're looking for
+ describe what you're looking for
)}
- {error && errorType === 'verification' && (
-
- Please verify your email address to use AI-powered search. Check your inbox for a
- verification link.
-
- )}
{error && errorType === 'limit' && (
You've reached the weekly AI usage limit. It will reset automatically next week.
diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx
index bfaba8b..ef36b1b 100644
--- a/frontend/src/components/map/AreaPane.tsx
+++ b/frontend/src/components/map/AreaPane.tsx
@@ -78,7 +78,7 @@ export default function AreaPane({
}
title="No area selected"
- description="Click a hexagon or postcode to view area statistics"
+ description="Click any coloured area on the map to see crime, schools, prices, and more"
centered
/>
);
diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx
index 374f8ee..defba35 100644
--- a/frontend/src/components/map/FeatureBrowser.tsx
+++ b/frontend/src/components/map/FeatureBrowser.tsx
@@ -10,23 +10,18 @@ import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
-import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
-import type { ComponentType } from 'react';
+import { PlusIcon, InfoIcon } from '../ui/icons';
+import { IconButton } from '../ui/IconButton';
+import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
import {
TRANSPORT_MODES,
MODE_LABELS,
MODE_DESCRIPTIONS,
+ MODE_ICONS,
type TransportMode,
type TravelTimeEntry,
} from '../../hooks/useTravelTime';
-const MODE_ICONS: Record> = {
- car: CarIcon,
- bicycle: BicycleIcon,
- walking: WalkingIcon,
- transit: TransitIcon,
-};
-
interface FeatureBrowserProps {
availableFeatures: FeatureMeta[];
allFeatures: FeatureMeta[];
@@ -58,6 +53,7 @@ export default function FeatureBrowser({
}: FeatureBrowserProps) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState(null);
+ const [travelInfoMode, setTravelInfoMode] = useState(null);
const [expandedGroups, toggleGroup] = useCollapsibleGroups();
const availableTravelModes = useTravelModes();
@@ -89,7 +85,19 @@ export default function FeatureBrowser({
const showTravelModes =
visibleModes.length > 0 &&
(!search ||
- 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
+ 'travel time journey commute car bicycle walking transit transport station tube train'.includes(
+ search.toLowerCase()
+ ));
+
+ // Ensure "Transport" group exists when travel modes should be shown
+ const mergedGrouped = useMemo(() => {
+ if (!showTravelModes) return grouped;
+ if (grouped.some((g) => g.name === 'Transport')) return grouped;
+ const groups = [...grouped];
+ const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area');
+ groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] });
+ return groups;
+ }, [grouped, showTravelModes]);
return (
<>
@@ -97,55 +105,7 @@ export default function FeatureBrowser({
- {showTravelModes && (
-
-
toggleGroup('Travel Time')}
- className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
- >
-
- {visibleModes.length}
-
-
- {(isSearching || expandedGroups.has('Travel Time')) &&
- visibleModes.map((mode) => {
- const ModeIcon = MODE_ICONS[mode];
- return (
-
-
onAddTravelTimeEntry(mode)}
- >
-
-
-
- {MODE_LABELS[mode]}
-
-
- {MODE_DESCRIPTIONS[mode]}
-
-
-
-
-
onAddTravelTimeEntry(mode)}
- title={`Add ${MODE_LABELS[mode]} travel time`}
- className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
- >
-
-
-
-
- );
- })}
-
- )}
- {grouped.map((group) => {
+ {mergedGrouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
return (
@@ -156,38 +116,84 @@ export default function FeatureBrowser({
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
- {group.features.length}
+ {group.features.length +
+ (group.name === 'Transport' && showTravelModes ? visibleModes.length : 0)}
- {isExpanded &&
- group.features.map((f) => {
- const isPinned = pinnedFeature === f.name;
- return (
-
-
-
- {f.description && (
-
- {f.description}
-
- )}
+ {isExpanded && (
+ <>
+ {group.features.map((f) => {
+ const isPinned = pinnedFeature === f.name;
+ return (
+
+
+
+ {f.description && (
+
+ {f.description}
+
+ )}
+
+
-
-
- );
- })}
+ );
+ })}
+ {group.name === 'Transport' &&
+ showTravelModes &&
+ visibleModes.map((mode) => {
+ const ModeIcon = MODE_ICONS[mode];
+ return (
+
+
onAddTravelTimeEntry(mode)}
+ >
+
+
+
+ {MODE_LABELS[mode]}
+
+
+ {MODE_DESCRIPTIONS[mode]}
+
+
+
+
+
setTravelInfoMode(mode)}
+ title="Feature info"
+ >
+
+
+
onAddTravelTimeEntry(mode)}
+ title={`Add ${MODE_LABELS[mode]} travel time`}
+ className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
+ >
+
+
+
+
+ );
+ })}
+ >
+ )}
);
})}
- {grouped.length === 0 ? (
+ {mergedGrouped.length === 0 ? (
}
title={search ? 'No matching features' : 'All features are active'}
@@ -237,6 +243,9 @@ export default function FeatureBrowser({
onNavigateToSource={onNavigateToSource}
/>
)}
+ {travelInfoMode && (
+
setTravelInfoMode(null)} />
+ )}
>
);
}
diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx
index 7692700..7aec19a 100644
--- a/frontend/src/components/map/Filters.tsx
+++ b/frontend/src/components/map/Filters.tsx
@@ -1,15 +1,12 @@
import { memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { Slider } from '../ui/Slider';
-import { LightbulbIcon } from '../ui/icons';
+import { ChevronIcon, LightbulbIcon } from '../ui/icons';
-import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import type { FeatureMeta, FeatureFilters } from '../../types';
-import { formatFilterValue, buildPercentileScale } from '../../lib/format';
+import { formatFilterValue, parseInputValue, buildPercentileScale } from '../../lib/format';
import type { PercentileScale } from '../../lib/format';
-import { groupFeaturesByCategory } from '../../lib/features';
-import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import InfoPopup from '../ui/InfoPopup';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
@@ -26,6 +23,73 @@ import {
type ListingType = 'historical' | 'buy' | 'rent';
+function EditableLabel({
+ value,
+ formatted,
+ onCommit,
+ prefix,
+ suffix,
+ className,
+ style,
+}: {
+ value: number;
+ formatted: string;
+ onCommit: (v: number) => void;
+ prefix?: string;
+ suffix?: string;
+ className?: string;
+ style?: React.CSSProperties;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [text, setText] = useState('');
+ const inputRef = useRef(null);
+
+ const startEdit = () => {
+ setEditing(true);
+ setText(String(Math.round(value)));
+ };
+
+ const commit = () => {
+ const parsed = parseInputValue(text, { prefix, suffix });
+ if (parsed != null) onCommit(parsed);
+ setEditing(false);
+ };
+
+ useEffect(() => {
+ if (editing) {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }
+ }, [editing]);
+
+ if (editing) {
+ return (
+ setText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') commit();
+ if (e.key === 'Escape') setEditing(false);
+ }}
+ onBlur={commit}
+ className="absolute w-16 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400"
+ style={style}
+ />
+ );
+ }
+
+ return (
+
+ {formatted}
+
+ );
+}
+
function SliderLabels({
min,
max,
@@ -34,6 +98,8 @@ function SliderLabels({
isAtMin,
isAtMax,
raw,
+ feature,
+ onValueChange,
}: {
min: number;
max: number;
@@ -42,18 +108,55 @@ function SliderLabels({
isAtMin?: boolean;
isAtMax?: boolean;
raw?: boolean;
+ feature?: FeatureMeta;
+ onValueChange?: (v: [number, number]) => void;
}) {
const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100;
const rightPct = ((value[1] - min) / range) * 100;
const labels = displayValues || value;
+
+ const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw);
+ const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], raw);
+
+ // Smoothly spread labels apart as thumbs get close to prevent overlap.
+ // t=1 (centered) when far apart, t=0 (split) when touching.
+ const SPREAD_THRESHOLD = 20; // percentage gap below which labels start separating
+ const gapPct = rightPct - leftPct;
+ const t = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD));
+ const leftTranslate = `translateX(${-100 + t * 50}%)`;
+ const rightTranslate = `translateX(${-t * 50}%)`;
+
+ if (feature && onValueChange) {
+ return (
+
+ onValueChange([Math.min(v, labels[1]), labels[1]])}
+ prefix={feature.prefix}
+ suffix={feature.suffix}
+ style={{ left: `${leftPct}%`, transform: leftTranslate }}
+ />
+ onValueChange([labels[0], Math.max(v, labels[0])])}
+ prefix={feature.prefix}
+ suffix={feature.suffix}
+ style={{ left: `${rightPct}%`, transform: rightTranslate }}
+ />
+
+ );
+ }
+
return (
-
- {isAtMin ? 'min' : formatFilterValue(labels[0], raw)}
+
+ {minLabel}
-
- {isAtMax ? 'max' : formatFilterValue(labels[1], raw)}
+
+ {maxLabel}
);
@@ -249,29 +352,25 @@ export default memo(function Filters({
const scrollRef = useRef(null);
const [showPhilosophy, setShowPhilosophy] = useState(false);
const [activeInfoFeature, setActiveInfoFeature] = useState(null);
- const [collapsedGroups, toggleGroup, expandGroup] = useCollapsibleGroups();
-
+ const [addFilterCollapsed, setAddFilterCollapsed] = useState(false);
const activeEntryCount = travelTimeEntries.length;
const pendingScrollRef = useRef(null);
const handleAddAndScroll = useCallback(
(name: string) => {
- const feature = features.find((f) => f.name === name);
- if (feature?.group) expandGroup(feature.group);
pendingScrollRef.current = name;
onAddFilter(name);
},
- [onAddFilter, features, expandGroup]
+ [onAddFilter]
);
const handleAddTravelTimeAndScroll = useCallback(
(mode: TransportMode) => {
- expandGroup('Transport');
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
onTravelTimeAddEntry(mode);
},
- [onTravelTimeAddEntry, travelTimeEntries.length, expandGroup]
+ [onTravelTimeAddEntry, travelTimeEntries.length]
);
useEffect(() => {
@@ -283,21 +382,6 @@ export default memo(function Filters({
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [enabledFeatureList, travelTimeEntries]);
- const enabledGroups = useMemo(
- () => groupFeaturesByCategory(enabledFeatureList),
- [enabledFeatureList]
- );
-
- // Ensure "Transport" group exists in active filters when travel time entries are present
- const mergedGroups = useMemo(() => {
- if (travelTimeEntries.length === 0) return enabledGroups;
- if (enabledGroups.some((g) => g.name === 'Transport')) return enabledGroups;
- const groups = [...enabledGroups];
- const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area');
- groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] });
- return groups;
- }, [enabledGroups, travelTimeEntries.length]);
-
const percentileScales = useMemo(() => {
const scales = new Map();
for (const f of features) {
@@ -313,12 +397,14 @@ export default memo(function Filters({
return (
-
-
+
+
-
+
Active Filters
{badgeCount > 0 && (
@@ -368,226 +454,190 @@ export default memo(function Filters({
Finding the Perfect Postcode
- {travelTimeEntries.length > 0 && (
-
-
toggleGroup('Travel Time')}
- className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
- >
-
- {travelTimeEntries.length}
-
-
- {!collapsedGroups.has('Travel Time') && (
-
- {travelTimeEntries.map((entry, index) => (
-
- onTogglePin(travelFieldKey(entry))}
- onSetDestination={(slug, label) =>
- onTravelTimeSetDestination(index, slug, label)
- }
- onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
- onToggleBest={() => onTravelTimeToggleBest(index)}
- onRemove={() => onTravelTimeRemoveEntry(index)}
- />
-
- ))}
-
- )}
-
- )}
-
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
- Browse features below and click + to add a filter
+ Add filters below to narrow the map to areas that match
)}
- {enabledGroups.map((group) => {
- const isExpanded = !collapsedGroups.has(group.name);
- return (
-
-
toggleGroup(group.name)}
- className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
- >
-
- {group.features.length}
-
-
- {isExpanded && (
-
- {group.features.map((feature) => {
- if (feature.type === 'enum') {
- const selectedValues = (filters[feature.name] as string[]) || [];
- const allValues = feature.values || [];
- return (
-
-
-
-
-
-
- {allValues.map((val) => (
- {
- const next = selectedValues.includes(val)
- ? selectedValues.filter((v) => v !== val)
- : [...selectedValues, val];
- onFilterChange(feature.name, next);
- }}
- size="xs"
- />
- ))}
-
-
- );
- }
-
- const isActive = activeFeature === feature.name;
- const isPinned = pinnedFeature === feature.name;
- const hist = feature.histogram;
- const displayValue =
- isActive && dragValue
- ? dragValue
- : (filters[feature.name] as [number, number]) || [
- hist?.min ?? feature.min!,
- hist?.max ?? feature.max!,
- ];
- const scale = percentileScales.get(feature.name);
- const dataMin = hist?.min ?? feature.min!;
- const dataMax = hist?.max ?? feature.max!;
- const isAtMin = displayValue[0] <= dataMin;
- const isAtMax = displayValue[1] >= dataMax;
- const sliderValue: [number, number] = scale
- ? [
- isAtMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
- isAtMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
- ]
- : [
- isAtMin ? feature.min! : displayValue[0],
- isAtMax ? feature.max! : displayValue[1],
- ];
-
- return (
-
-
-
-
-
-
- {
- const step = feature.step ?? 1;
- const snap = (v: number) => Math.round(v / step) * step;
- onDragChange([
- pMin <= 0
- ? (hist?.min ?? feature.min!)
- : snap(scale.toValue(pMin)),
- pMax >= 100
- ? (hist?.max ?? feature.max!)
- : snap(scale.toValue(pMax)),
- ]);
- }
- : ([min, max]) =>
- onDragChange([
- min <= feature.min! ? (hist?.min ?? feature.min!) : min,
- max >= feature.max! ? (hist?.max ?? feature.max!) : max,
- ])
- }
- onPointerDown={() => onDragStart(feature.name)}
- onPointerUp={() => onDragEnd()}
- />
-
-
-
- );
- })}
-
- )}
+
+ {travelTimeEntries.map((entry, index) => (
+
+ onTogglePin(travelFieldKey(entry))}
+ onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
+ onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
+ onToggleBest={() => onTravelTimeToggleBest(index)}
+ onRemove={() => onTravelTimeRemoveEntry(index)}
+ />
- );
- })}
+ ))}
+ {enabledFeatureList.map((feature) => {
+ if (feature.type === 'enum') {
+ const selectedValues = (filters[feature.name] as string[]) || [];
+ const allValues = feature.values || [];
+ return (
+
+
+
+
+
+
+ {allValues.map((val) => (
+ {
+ const next = selectedValues.includes(val)
+ ? selectedValues.filter((v) => v !== val)
+ : [...selectedValues, val];
+ onFilterChange(feature.name, next);
+ }}
+ size="xs"
+ />
+ ))}
+
+
+ );
+ }
+
+ const isActive = activeFeature === feature.name;
+ const isPinned = pinnedFeature === feature.name;
+ const hist = feature.histogram;
+ const displayValue =
+ isActive && dragValue
+ ? dragValue
+ : (filters[feature.name] as [number, number]) || [
+ hist?.min ?? feature.min!,
+ hist?.max ?? feature.max!,
+ ];
+ const scale = percentileScales.get(feature.name);
+ const dataMin = hist?.min ?? feature.min!;
+ const dataMax = hist?.max ?? feature.max!;
+ const clampMin = displayValue[0] <= dataMin;
+ const clampMax = displayValue[1] >= dataMax;
+ const isAtMin = displayValue[0] === dataMin;
+ const isAtMax = displayValue[1] === dataMax;
+ const sliderValue: [number, number] = scale
+ ? [
+ clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
+ clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
+ ]
+ : [
+ clampMin ? feature.min! : displayValue[0],
+ clampMax ? feature.max! : displayValue[1],
+ ];
+
+ return (
+
+
+
+
+
+
+ {
+ const step = feature.step ?? 1;
+ const snap = (v: number) => Math.round(v / step) * step;
+ onDragChange([
+ pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
+ pMax >= 100
+ ? (hist?.max ?? feature.max!)
+ : snap(scale.toValue(pMax)),
+ ]);
+ }
+ : ([min, max]) =>
+ onDragChange([
+ min <= feature.min! ? (hist?.min ?? feature.min!) : min,
+ max >= feature.max! ? (hist?.max ?? feature.max!) : max,
+ ])
+ }
+ onPointerDown={() => onDragStart(feature.name)}
+ onPointerUp={() => onDragEnd()}
+ />
+ onFilterChange(feature.name, v)}
+ />
+
+
+ );
+ })}
+
-
-
- Add Filter
-
-
-
+ setAddFilterCollapsed((v) => !v)}
+ className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
+ >
+ Add Filter
+
-
+
+ {!addFilterCollapsed && (
+
+
+
+ )}
{showPhilosophy && (
@@ -595,79 +645,72 @@ export default memo(function Filters({
Start with your must-haves, then layer on nice-to-haves. The map narrows down as you
- add filters — the areas that survive are your best matches.
+ add filters. The areas that survive are your best matches.
-
-
- 1. Budget & property basics
-
-
- Set your price range, minimum floor area, and property type. If you need a lease
- over freehold (or vice versa), filter for that too. This eliminates most of the map
- immediately.
-
+
+
+
+ 1
+
+
+
+ Budget & basics
+ {' '}
+ (price range, floor area, property type)
+
+
+
+
+ 2
+
+
+ Commute {' '}
+ (travel time to your workplace by car, bike, or transit)
+
+
+
+
+ 3
+
+
+ Safety {' '}
+ (crime rates, noise levels, ground stability)
+
+
+
+
+ 4
+
+
+ Schools {' '}
+ (nearby Ofsted-rated Good or Outstanding schools)
+
+
+
+
+ 5
+
+
+ Lifestyle {' '}
+ (restaurants, parks, broadband speed)
+
+
+
+
+ 6
+
+
+ Energy {' '}
+ (EPC ratings for lower bills and fewer surprises)
+
+
-
-
- 2. Commute & transport
-
-
- Add a travel time filter to your workplace — choose public transport or
- cycling and set your maximum tolerable commute. You can also filter by how many
- stations are within walking distance.
-
-
-
-
-
- 3. Safety & environment
-
-
- Use the crime filters to cap serious or minor crime rates. Check road noise levels
- if you're a light sleeper, and environmental risk filters for ground stability
- concerns.
-
-
-
-
-
- 4. Schools & education
-
-
- Filter by the number of Ofsted-rated Good or Outstanding primary and secondary
- schools nearby. The education deprivation score captures broader area-level
- attainment.
-
-
-
-
-
- 5. Lifestyle & amenities
-
-
- Want restaurants, parks, or grocery shops within walking distance? Filter by nearby
- amenity counts. Broadband speed filters help if you work from home.
-
-
-
-
-
- 6. Energy & running costs
-
-
- EPC ratings from A to G indicate energy efficiency. Filter for better ratings to
- find homes with lower bills and fewer upgrade headaches.
-
-
-
-
-
- Tip: if nothing survives your filters, relax one constraint at a time to see which
- compromise unlocks the most options.
-
-
+
+ Tip: if nothing survives, relax one constraint at a time to see which compromise
+ unlocks the most options.
+
{onResetTutorial && (
Dashed line {' '}
- indicates the global average
+ indicates the national average
diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx
index 669a73a..3d08e9a 100644
--- a/frontend/src/components/map/HoverCard.tsx
+++ b/frontend/src/components/map/HoverCard.tsx
@@ -111,7 +111,7 @@ export default memo(function HoverCard({
)}
{/* Hint */}
-
diff --git a/frontend/src/components/map/JourneyInstructions.tsx b/frontend/src/components/map/JourneyInstructions.tsx
index a884aa6..e5fa5da 100644
--- a/frontend/src/components/map/JourneyInstructions.tsx
+++ b/frontend/src/components/map/JourneyInstructions.tsx
@@ -64,6 +64,28 @@ function getRouteDisplay(mode: string): { label: string; color: string; darkText
return { label: clean, color: '#6b7280', darkText: false };
}
+/** Returns a Unix timestamp for the next Monday at 07:30 local time. */
+function nextMondayAt730(): number {
+ const now = new Date();
+ const day = now.getDay(); // 0=Sun … 6=Sat
+ const daysUntil = day === 0 ? 1 : day === 1 ? 7 : 8 - day;
+ const monday = new Date(now);
+ monday.setDate(now.getDate() + daysUntil);
+ monday.setHours(7, 30, 0, 0);
+ return Math.floor(monday.getTime() / 1000);
+}
+
+function googleMapsUrl(postcode: string, destination: string): string {
+ const ts = nextMondayAt730();
+ const origin = encodeURIComponent(postcode);
+ const dest = encodeURIComponent(destination);
+ // The official api=1 URL scheme doesn't support departure_time.
+ // Use the undocumented data= path parameter with protobuf-like encoding:
+ // !3e3 = transit, !6e0 = "depart at", !7e2 = local time, !8j = timestamp
+ const data = `!4m6!4m5!2m3!6e0!7e2!8j${ts}!3e3`;
+ return `https://www.google.com/maps/dir/${origin}/${dest}/data=${data}`;
+}
+
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
return [...legs]
.reverse()
@@ -219,7 +241,7 @@ export default function JourneyInstructions({
To {j.label || j.slug}
- {displayLegs && displayLegs.length > 0 && (
+ {!j.loading && totalMin > 0 && (
{totalMin} min
@@ -235,6 +257,57 @@ export default function JourneyInstructions({
{displayLegs.map((leg, i) => (
))}
+
+ View on Google Maps
+
+
+
+
+
+ ) : j.minutes != null ? (
+
) : (
diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx
index 448c051..4519b16 100644
--- a/frontend/src/components/map/Map.tsx
+++ b/frontend/src/components/map/Map.tsx
@@ -25,6 +25,7 @@ import LocationSearch, { type SearchedLocation } from './LocationSearch';
import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
import { LogoIcon } from '../ui/icons/LogoIcon';
+import { CloseIcon } from '../ui/icons/CloseIcon';
import type { FeatureFilters } from '../../types';
import { useDeckLayers } from '../../hooks/useDeckLayers';
import { MODE_LABELS, type TravelTimeEntry } from '../../hooks/useTravelTime';
@@ -167,6 +168,7 @@ export default memo(function Map({
const {
layers,
popupInfo,
+ clearPopupInfo,
hoverPosition,
countRange,
postcodeCountRange,
@@ -309,7 +311,7 @@ export default memo(function Map({
))}
{popupInfo && (
+
+
+
{popupInfo.isCluster ? (
diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx
index 0034aec..62b93b7 100644
--- a/frontend/src/components/map/MapPage.tsx
+++ b/frontend/src/components/map/MapPage.tsx
@@ -72,6 +72,7 @@ interface MapPageProps {
onUnsaveProperty?: (id: string) => void;
isPropertySaved?: (address?: string, postcode?: string) => boolean;
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
+ deferTutorial?: boolean;
}
export default function MapPage({
@@ -99,6 +100,7 @@ export default function MapPage({
onUnsaveProperty,
isPropertySaved,
getSavedPropertyId,
+ deferTutorial = false,
}: MapPageProps) {
const [selectedPOICategories, setSelectedPOICategories] =
useState
>(initialPOICategories);
@@ -153,6 +155,14 @@ export default function MapPage({
const handleAiFilterSubmit = useCallback(
async (query: string) => {
+ // Derive current listing type from Listing status filter
+ const listingVal = filters['Listing status'] as string[] | undefined;
+ const listingType = listingVal?.includes('For sale')
+ ? 'buy'
+ : listingVal?.includes('For rent')
+ ? 'rent'
+ : 'historical';
+
// Build context from current filters for conversational refinement
const context = {
filters,
@@ -165,7 +175,11 @@ export default function MapPage({
};
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
- const result = await aiFilters.fetchAiFilters(query, hasContext ? context : undefined);
+ const result = await aiFilters.fetchAiFilters(
+ query,
+ hasContext ? context : undefined,
+ listingType
+ );
if (!result) return;
handleSetFilters(result.filters);
// Always sync travel time entries — clear stale ones when AI returns none
@@ -354,7 +368,7 @@ export default function MapPage({
selection.areaStats?.central_postcode,
]);
- const tutorial = useTutorial(initialLoading, isMobile);
+ const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(() => {
@@ -418,7 +432,14 @@ export default function MapPage({
? mapData.postcodeData.length > 0
: mapData.data.length > 0;
if (hasData) {
- window.__screenshot_ready = true;
+ // Wait for deck.gl to actually paint: in interleaved MapboxOverlay mode,
+ // hexagons render during MapLibre's rAF cycle. Double-rAF ensures at
+ // least one full paint has completed before we signal readiness.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ window.__screenshot_ready = true;
+ });
+ });
}
}
}, [
diff --git a/frontend/src/components/map/POIPane.tsx b/frontend/src/components/map/POIPane.tsx
index af9b588..0aace7e 100644
--- a/frontend/src/components/map/POIPane.tsx
+++ b/frontend/src/components/map/POIPane.tsx
@@ -84,7 +84,7 @@ export default function POIPane({
const selectedCount = selectedCategories.size;
return (
-
+
diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx
index 6c632fc..255ecef 100644
--- a/frontend/src/components/map/PropertiesPane.tsx
+++ b/frontend/src/components/map/PropertiesPane.tsx
@@ -52,7 +52,7 @@ export function PropertiesPane({
}
title="No area selected"
- description="Click a hexagon or postcode to view area statistics"
+ description="Click any coloured area on the map to see crime, schools, prices, and more"
centered
/>
);
@@ -77,10 +77,9 @@ export function PropertiesPane({
}
>
- Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price
- Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy
- ratings, construction year, and tenure from EPC surveys, plus the most recent sale price
- from the Land Registry.
+ Prices come from HM Land Registry (what buyers actually paid). Floor area, energy
+ ratings, construction year, and tenure come from official EPC surveys. Both sources are
+ matched by address within each postcode.
)}
diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx
index 45e7d46..02b68ba 100644
--- a/frontend/src/components/map/TravelTimeCard.tsx
+++ b/frontend/src/components/map/TravelTimeCard.tsx
@@ -4,24 +4,13 @@ import { IconButton } from '../ui/IconButton';
import { PillToggle } from '../ui/PillToggle';
import { DestinationDropdown } from '../ui/DestinationDropdown';
import InfoPopup from '../ui/InfoPopup';
+import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { InfoIcon } from '../ui/icons/InfoIcon';
-import { CarIcon } from '../ui/icons/CarIcon';
-import { BicycleIcon } from '../ui/icons/BicycleIcon';
-import { WalkingIcon } from '../ui/icons/WalkingIcon';
-import { TransitIcon } from '../ui/icons/TransitIcon';
import { formatFilterValue } from '../../lib/format';
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
-import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
-import type { ComponentType } from 'react';
-
-const MODE_ICONS: Record> = {
- car: CarIcon,
- bicycle: BicycleIcon,
- walking: WalkingIcon,
- transit: TransitIcon,
-};
+import { MODE_LABELS, MODE_ICONS, type TransportMode } from '../../hooks/useTravelTime';
interface TravelTimeCardProps {
mode: TransportMode;
@@ -78,15 +67,11 @@ export function TravelTimeCard({
Travel Time ({MODE_LABELS[mode]})
- setShowInfo(true)}
- className="p-1 -m-0.5 rounded text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 hover:bg-warm-100 dark:hover:bg-warm-700 shrink-0"
- title="Feature info"
- >
-
-
+
setShowInfo(true)} title="Feature info">
+
+
{slug && (
)}
- {showInfo && (
- setShowInfo(false)}>
-
- Shows how long it takes to reach the selected destination from each area
- {mode === 'transit'
- ? ' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.'
- : mode === 'car'
- ? ' by car, based on typical road speeds and the road network.'
- : mode === 'bicycle'
- ? ' by bicycle, using cycle-friendly routes.'
- : ' on foot, using pedestrian paths and pavements.'}{' '}
- Use the slider to filter areas within your preferred commute time.
-
-
- )}
+ {showInfo && setShowInfo(false)} />}
{showBestInfo && (
setShowBestInfo(false)}>
diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx
index 5ab32fc..f3a4b4c 100644
--- a/frontend/src/components/ui/AuthModal.tsx
+++ b/frontend/src/components/ui/AuthModal.tsx
@@ -87,7 +87,7 @@ export default function AuthModal({
if (e.target === e.currentTarget) onClose();
}}
>
-
+
{/* Header */}
@@ -127,6 +127,13 @@ export default function AuthModal({
)}
+ {/* Value prop */}
+ {view !== 'forgot' && (
+
+ Save searches, bookmark properties, and pick up where you left off.
+
+ )}
+
{/* OAuth buttons (hidden in forgot view) */}
{view !== 'forgot' && (
<>
diff --git a/frontend/src/components/ui/FeatureIcons.tsx b/frontend/src/components/ui/FeatureIcons.tsx
index 08d0d7e..1dbce65 100644
--- a/frontend/src/components/ui/FeatureIcons.tsx
+++ b/frontend/src/components/ui/FeatureIcons.tsx
@@ -22,8 +22,8 @@ export function FeatureActions({
return (
{feature.detail && onShowInfo && (
-
onShowInfo(feature)} title="Feature info">
-
+ onShowInfo(feature)} title="Feature info" size="md">
+
)}
}
{feature.name}
diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx
index 968f5e1..6ff1baf 100644
--- a/frontend/src/components/ui/Header.tsx
+++ b/frontend/src/components/ui/Header.tsx
@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
-import { shortenUrl } from '../../lib/api';
+import { shortenUrl, prewarmScreenshot } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
@@ -96,6 +96,7 @@ export default function Header({
doCopy(window.location.href);
return;
}
+ prewarmScreenshot(params);
setSharing(true);
try {
const shortUrl = await shortenUrl(params);
@@ -144,22 +145,13 @@ export default function Header({
Dashboard
{user && (
- <>
- navLink('saved', e)}
- >
- Saved
-
- navLink('invites', e)}
- >
- Invite
-
- >
+ navLink('invites', e)}
+ >
+ Invite Friends
+
)}
)}
+ {user && (
+ navLink('saved', e)}
+ >
+ Saved
+
+ )}
)}
@@ -238,7 +239,13 @@ export default function Header({
{!isMobile && (
<>
{user ? (
-
+
) : (
<>
{title}
diff --git a/frontend/src/components/ui/MobileMenu.tsx b/frontend/src/components/ui/MobileMenu.tsx
index dd8db5a..5c23541 100644
--- a/frontend/src/components/ui/MobileMenu.tsx
+++ b/frontend/src/components/ui/MobileMenu.tsx
@@ -88,9 +88,9 @@ export default function MobileMenu({
{user?.subscription !== 'licensed' &&
!user?.isAdmin &&
mobileNavItem('pricing', 'Pricing')}
- {user && mobileNavItem('saved', 'Saved')}
- {user && mobileNavItem('invites', 'Invite')}
+ {user && mobileNavItem('invites', 'Invite Friends')}
{user && mobileNavItem('account', 'Account')}
+ {user && mobileNavItem('saved', 'Saved')}
{/* Dashboard actions */}
{activePage === 'dashboard' && (
diff --git a/frontend/src/components/ui/TravelTimeInfoPopup.tsx b/frontend/src/components/ui/TravelTimeInfoPopup.tsx
new file mode 100644
index 0000000..9a2be71
--- /dev/null
+++ b/frontend/src/components/ui/TravelTimeInfoPopup.tsx
@@ -0,0 +1,27 @@
+import InfoPopup from './InfoPopup';
+import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
+
+const MODE_INFO: Record
= {
+ transit:
+ ' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.',
+ car: ' by car, based on typical road speeds and the road network.',
+ bicycle: ' by bicycle, using cycle-friendly routes.',
+ walking: ' on foot, using pedestrian paths and pavements.',
+};
+
+export function TravelTimeInfoPopup({
+ mode,
+ onClose,
+}: {
+ mode: TransportMode;
+ onClose: () => void;
+}) {
+ return (
+
+
+ Shows how long it takes to reach the selected destination from each area
+ {MODE_INFO[mode]} Use the slider to filter areas within your preferred commute time.
+
+
+ );
+}
diff --git a/frontend/src/components/ui/UpgradeModal.tsx b/frontend/src/components/ui/UpgradeModal.tsx
index 41f7923..b5a574e 100644
--- a/frontend/src/components/ui/UpgradeModal.tsx
+++ b/frontend/src/components/ui/UpgradeModal.tsx
@@ -60,9 +60,10 @@ export default function UpgradeModal({
{/* Header */}
-
Unlock the full map
+
See all of England
- Free users can explore inner London. Upgrade for lifetime access to all of England.
+ You're currently exploring inner London. Get lifetime access to every postcode,
+ every filter, every neighbourhood. One payment, forever.
@@ -118,7 +119,7 @@ export default function UpgradeModal({
onClick={onZoomToFreeZone}
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
>
- Or zoom back to demo area
+ Or continue exploring inner London
diff --git a/frontend/src/components/ui/UserMenu.tsx b/frontend/src/components/ui/UserMenu.tsx
index cc1d861..e6c4cce 100644
--- a/frontend/src/components/ui/UserMenu.tsx
+++ b/frontend/src/components/ui/UserMenu.tsx
@@ -1,5 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
+import type { Page } from './Header';
+import { PAGE_PATHS } from './Header';
import { SunIcon } from './icons/SunIcon';
import { MoonIcon } from './icons/MoonIcon';
@@ -8,11 +10,13 @@ export default function UserMenu({
theme,
onToggleTheme,
onLogout,
+ onNavigate,
}: {
user: AuthUser;
theme: 'light' | 'dark';
onToggleTheme: () => void;
onLogout: () => void;
+ onNavigate: (page: Page) => void;
}) {
const [open, setOpen] = useState(false);
const menuRef = useRef(null);
@@ -55,7 +59,7 @@ export default function UserMenu({
: 'bg-warm-100 text-warm-500 dark:bg-warm-700 dark:text-warm-400'
}`}
>
- {user.subscription === 'licensed' || user.isAdmin ? 'Pro' : 'Free'}
+ {user.subscription === 'licensed' || user.isAdmin ? 'Full Access' : 'Inner London'}
@@ -72,8 +76,13 @@ export default function UserMenu({
Theme: {theme === 'light' ? 'Light' : 'Dark'}
setOpen(false)}
+ href={PAGE_PATHS.account}
+ onClick={(e) => {
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
+ e.preventDefault();
+ setOpen(false);
+ onNavigate('account');
+ }}
className="block w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Account
diff --git a/frontend/src/components/ui/VerificationBanner.tsx b/frontend/src/components/ui/VerificationBanner.tsx
deleted file mode 100644
index f63b1e9..0000000
--- a/frontend/src/components/ui/VerificationBanner.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { useState, useCallback } from 'react';
-import { SpinnerIcon } from './icons/SpinnerIcon';
-
-export default function VerificationBanner({
- email,
- onRequestVerification,
- onDismiss,
-}: {
- email: string;
- onRequestVerification: (email: string) => Promise;
- onDismiss: () => void;
-}) {
- const [sending, setSending] = useState(false);
- const [sent, setSent] = useState(false);
-
- const handleResend = useCallback(async () => {
- setSending(true);
- try {
- await onRequestVerification(email);
- setSent(true);
- setTimeout(() => setSent(false), 3000);
- } catch {
- // Error handled by hook
- } finally {
- setSending(false);
- }
- }, [email, onRequestVerification]);
-
- return (
-
-
- Please verify your email address. Check your inbox.
-
-
-
- {sending && }
- {sent ? 'Sent!' : 'Resend'}
-
-
- ×
-
-
-
- );
-}
diff --git a/frontend/src/hooks/useAiFilters.ts b/frontend/src/hooks/useAiFilters.ts
index 40440b9..93110cd 100644
--- a/frontend/src/hooks/useAiFilters.ts
+++ b/frontend/src/hooks/useAiFilters.ts
@@ -17,9 +17,11 @@ export interface AiFiltersResult {
notes: string;
/** Human-readable summary of what was set */
summary: string;
+ /** The listing mode used (historical/buy/rent) */
+ listingType: string;
}
-export type AiFilterErrorType = 'auth' | 'verification' | 'limit' | 'error';
+export type AiFilterErrorType = 'auth' | 'limit' | 'error';
/** Context of currently active filters, sent for conversational refinement. */
export interface AiFiltersContext {
@@ -28,7 +30,11 @@ export interface AiFiltersContext {
}
interface UseAiFiltersResult {
- fetchAiFilters: (query: string, context?: AiFiltersContext) => Promise;
+ fetchAiFilters: (
+ query: string,
+ context?: AiFiltersContext,
+ listingType?: string
+ ) => Promise;
loading: boolean;
error: string | null;
errorType: AiFilterErrorType | null;
@@ -41,6 +47,8 @@ function buildSummary(filters: FeatureFilters, travelTimeFilters: AiTravelTimeFi
const parts: string[] = [];
for (const [name, value] of Object.entries(filters)) {
+ // Skip Listing status — shown via the mode selector UI
+ if (name === 'Listing status') continue;
if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'number') {
parts.push(name);
} else if (Array.isArray(value)) {
@@ -67,7 +75,11 @@ export function useAiFilters(): UseAiFiltersResult {
const abortRef = useRef(null);
const fetchAiFilters = useCallback(
- async (query: string, context?: AiFiltersContext): Promise => {
+ async (
+ query: string,
+ context?: AiFiltersContext,
+ listingType?: string
+ ): Promise => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
@@ -81,6 +93,7 @@ export function useAiFilters(): UseAiFiltersResult {
try {
const url = apiUrl('ai-filters');
const bodyObj: Record = { query };
+ if (listingType) bodyObj.listing_type = listingType;
if (context) {
bodyObj.context = {
filters: context.filters,
@@ -102,9 +115,6 @@ export function useAiFilters(): UseAiFiltersResult {
if (response.status === 401) {
setErrorType('auth');
setError(text || 'Login required');
- } else if (response.status === 403) {
- setErrorType('verification');
- setError(text || 'Email verification required');
} else if (response.status === 429) {
setErrorType('limit');
setError(text || 'Weekly usage limit reached');
@@ -133,6 +143,7 @@ export function useAiFilters(): UseAiFiltersResult {
travelTimeFilters,
notes: json.notes || '',
summary: summaryText,
+ listingType: json.listing_type || 'historical',
};
setNotes(result.notes || null);
setSummary(summaryText);
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
index b488cdc..67976fe 100644
--- a/frontend/src/hooks/useAuth.ts
+++ b/frontend/src/hooks/useAuth.ts
@@ -5,7 +5,6 @@ import { trackEvent } from '../lib/analytics';
export interface AuthUser {
id: string;
email: string;
- verified: boolean;
isAdmin: boolean;
subscription: string;
newsletter: boolean;
@@ -18,7 +17,6 @@ function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser
return {
id: record.id,
email: record.email,
- verified: typeof record.verified === 'boolean' ? record.verified : false,
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
newsletter: typeof record.newsletter === 'boolean' ? record.newsletter : false,
@@ -136,20 +134,6 @@ export function useAuth() {
}
}, []);
- const requestVerification = useCallback(async (email: string) => {
- setLoading(true);
- setError(null);
- try {
- await pb.collection('users').requestVerification(email);
- } catch (err) {
- const msg = err instanceof Error ? err.message : 'Verification request failed';
- setError(msg);
- throw err;
- } finally {
- setLoading(false);
- }
- }, []);
-
const clearError = useCallback(() => {
setError(null);
}, []);
@@ -163,7 +147,6 @@ export function useAuth() {
loginWithOAuth,
logout,
requestPasswordReset,
- requestVerification,
refreshAuth,
clearError,
};
diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts
index ef2f75b..920af1e 100644
--- a/frontend/src/hooks/useDeckLayers.ts
+++ b/frontend/src/hooks/useDeckLayers.ts
@@ -95,7 +95,7 @@ export function useDeckLayers({
useEffect(() => {
if (!hasSelection) return;
setMarchTime(0);
- const id = setInterval(() => setMarchTime((t) => t + 0.3), 50);
+ const id = setInterval(() => setMarchTime((t) => (t + 0.3) % 10000), 50);
return () => clearInterval(id);
}, [hasSelection]);
@@ -771,9 +771,12 @@ export function useDeckLayers({
onHexagonHoverRef.current(null);
}, []);
+ const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
+
return {
layers,
popupInfo,
+ clearPopupInfo,
hoverPosition,
countRange,
postcodeCountRange,
diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts
index f48f3c2..73e27f3 100644
--- a/frontend/src/hooks/useFilters.ts
+++ b/frontend/src/hooks/useFilters.ts
@@ -1,4 +1,4 @@
-import { useState, useCallback, useMemo, useRef } from 'react';
+import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { trackEvent } from '../lib/analytics';
@@ -15,6 +15,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const pendingDragRef = useRef(null);
const dragActiveRef = useRef(null);
const dragValueRef = useRef<[number, number] | null>(null);
+ const undoStackRef = useRef([]);
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
@@ -34,17 +35,41 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const meta = features.find((f) => f.name === name);
if (!meta) return;
trackEvent('Filter Add', { feature: name });
- if (meta.type === 'enum' && meta.values) {
- setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
- } else if (meta.type === 'numeric' && meta.histogram) {
- setFilters((prev) => ({ ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] }));
- } else if (meta.min != null && meta.max != null) {
- setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
- }
+ setFilters((prev) => {
+ undoStackRef.current.push(prev);
+ if (undoStackRef.current.length > 50) undoStackRef.current.shift();
+ if (meta.type === 'enum' && meta.values) {
+ return { ...prev, [name]: [...meta.values!] };
+ } else if (meta.type === 'numeric' && meta.histogram) {
+ return { ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] };
+ } else if (meta.min != null && meta.max != null) {
+ return { ...prev, [name]: [meta.min!, meta.max!] };
+ }
+ return prev;
+ });
},
[features]
);
+ const handleUndo = useCallback(() => {
+ const prev = undoStackRef.current.pop();
+ if (prev) setFilters(prev);
+ }, []);
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
+ const target = e.target as HTMLElement;
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
+ return;
+ e.preventDefault();
+ handleUndo();
+ }
+ };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [handleUndo]);
+
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
setFilters((prev) => ({ ...prev, [name]: value }));
}, []);
diff --git a/frontend/src/hooks/useSavedSearches.ts b/frontend/src/hooks/useSavedSearches.ts
index 3c83211..98c9e0e 100644
--- a/frontend/src/hooks/useSavedSearches.ts
+++ b/frontend/src/hooks/useSavedSearches.ts
@@ -1,4 +1,4 @@
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useRef, useEffect } from 'react';
import pb from '../lib/pocketbase';
import { apiUrl, authHeaders } from '../lib/api';
import { trackEvent } from '../lib/analytics';
@@ -12,39 +12,94 @@ export interface SavedSearch {
created: string;
}
+const POLL_INTERVAL_MS = 2000;
+const MAX_POLL_ATTEMPTS = 15;
+
export function useSavedSearches(userId: string | null) {
const [searches, setSearches] = useState([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
+ const pollTimerRef = useRef | null>(null);
+ const pollAttemptsRef = useRef(0);
+ const userIdRef = useRef(userId);
+ userIdRef.current = userId;
+
+ const stopPolling = useCallback(() => {
+ if (pollTimerRef.current) {
+ clearInterval(pollTimerRef.current);
+ pollTimerRef.current = null;
+ }
+ pollAttemptsRef.current = 0;
+ }, []);
+
+ // Clean up polling on unmount or userId change
+ useEffect(() => stopPolling, [userId, stopPolling]);
+
+ const fetchRecords = useCallback(async (uid: string): Promise => {
+ const records = await pb.collection('saved_searches').getFullList({
+ sort: '-created',
+ filter: `user = "${uid}"`,
+ });
+ return records.map((r) => ({
+ id: r.id,
+ name: (r as Record).name as string,
+ params: (r as Record).params as string,
+ screenshotUrl: (r as Record).screenshot
+ ? pb.files.getURL(r, (r as Record).screenshot as string)
+ : '',
+ notes: ((r as Record).notes as string) || '',
+ created: r.created,
+ }));
+ }, []);
+
+ const startPolling = useCallback(() => {
+ if (pollTimerRef.current) return;
+ pollAttemptsRef.current = 0;
+ pollTimerRef.current = setInterval(async () => {
+ const uid = userIdRef.current;
+ if (!uid) {
+ stopPolling();
+ return;
+ }
+ pollAttemptsRef.current++;
+ if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
+ stopPolling();
+ return;
+ }
+ try {
+ const mapped = await fetchRecords(uid);
+ setSearches(mapped);
+ if (!mapped.some((s) => !s.screenshotUrl)) {
+ stopPolling();
+ }
+ } catch {
+ // Silent — background poll errors don't surface to UI
+ }
+ }, POLL_INTERVAL_MS);
+ }, [stopPolling, fetchRecords]);
+
const fetchSearches = useCallback(async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
- const records = await pb.collection('saved_searches').getFullList({
- sort: '-created',
- filter: `user = "${userId}"`,
- });
- setSearches(
- records.map((r) => ({
- id: r.id,
- name: (r as Record).name as string,
- params: (r as Record).params as string,
- screenshotUrl: (r as Record).screenshot
- ? pb.files.getURL(r, (r as Record).screenshot as string)
- : '',
- notes: ((r as Record).notes as string) || '',
- created: r.created,
- }))
- );
+ const mapped = await fetchRecords(userId);
+ setSearches(mapped);
+
+ // Poll for missing screenshots so they appear without a page refresh
+ if (mapped.some((s) => !s.screenshotUrl)) {
+ startPolling();
+ } else {
+ stopPolling();
+ }
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load searches');
} finally {
setLoading(false);
}
- }, [userId]);
+ }, [userId, fetchRecords, startPolling, stopPolling]);
const saveSearch = useCallback(
async (name: string) => {
@@ -112,6 +167,15 @@ export function useSavedSearches(userId: string | null) {
}
}, []);
+ const updateSearchName = useCallback(async (id: string, name: string) => {
+ try {
+ await pb.collection('saved_searches').update(id, { name });
+ setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s)));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to update name');
+ }
+ }, []);
+
return {
searches,
loading,
@@ -121,5 +185,6 @@ export function useSavedSearches(userId: string | null) {
saveSearch,
deleteSearch,
updateSearchNotes,
+ updateSearchName,
};
}
diff --git a/frontend/src/hooks/useTelemetry.ts b/frontend/src/hooks/useTelemetry.ts
new file mode 100644
index 0000000..ad03839
--- /dev/null
+++ b/frontend/src/hooks/useTelemetry.ts
@@ -0,0 +1,58 @@
+import { useEffect, useRef } from 'react';
+import { apiUrl } from '../lib/api';
+
+/**
+ * Sends a telemetry beacon every 30 seconds with session duration
+ * and the number of active filters (parsed from the URL `f` param).
+ * On the first beacon, also sends the entry path and referrer domain.
+ */
+export function useTelemetry() {
+ const startTime = useRef(Date.now());
+ const entryPath = useRef(window.location.pathname);
+ const referrer = useRef(extractReferrerDomain());
+ const sentEntry = useRef(false);
+
+ useEffect(() => {
+ const send = () => {
+ const sessionSeconds = Math.round((Date.now() - startTime.current) / 1000);
+
+ // Count active filters from URL (filters are encoded as `f=name:min:max;;name:val`)
+ const params = new URLSearchParams(window.location.search);
+ const filterStr = params.get('f') || '';
+ const filterCount = filterStr ? filterStr.split(';;').length : 0;
+
+ const payload: Record = {
+ session_seconds: sessionSeconds,
+ filter_count: filterCount,
+ };
+
+ // Include entrypoint info on first beacon only
+ if (!sentEntry.current) {
+ payload.entry_path = entryPath.current;
+ payload.referrer = referrer.current;
+ sentEntry.current = true;
+ }
+
+ navigator.sendBeacon(
+ apiUrl('telemetry'),
+ new Blob([JSON.stringify(payload)], { type: 'application/json' })
+ );
+ };
+
+ const interval = setInterval(send, 30_000);
+ return () => clearInterval(interval);
+ }, []);
+}
+
+/** Extract the referrer domain, or "direct" if none / same-origin. */
+function extractReferrerDomain(): string {
+ if (!document.referrer) return 'direct';
+ try {
+ const url = new URL(document.referrer);
+ // Same-origin navigation isn't a real external referrer
+ if (url.origin === window.location.origin) return 'direct';
+ return url.hostname;
+ } catch {
+ return 'direct';
+ }
+}
diff --git a/frontend/src/hooks/useTravelDestinations.ts b/frontend/src/hooks/useTravelDestinations.ts
index de54f6f..f7c5227 100644
--- a/frontend/src/hooks/useTravelDestinations.ts
+++ b/frontend/src/hooks/useTravelDestinations.ts
@@ -30,8 +30,12 @@ export function useTravelDestinations(mode: TransportMode) {
return res.json();
})
.then((data: { destinations: Destination[] }) => {
- cacheRef.current[mode] = data.destinations;
- setDestinations(data.destinations);
+ const normalized = data.destinations.map((d) => ({
+ ...d,
+ city: d.city === 'City of London' ? 'London' : d.city,
+ }));
+ cacheRef.current[mode] = normalized;
+ setDestinations(normalized);
})
.catch((err) => logNonAbortError('travel destinations', err))
.finally(() => setLoading(false));
diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts
index c475e3d..704caa6 100644
--- a/frontend/src/hooks/useTravelTime.ts
+++ b/frontend/src/hooks/useTravelTime.ts
@@ -1,4 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
+import type { ComponentType } from 'react';
+import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon } from '../components/ui/icons';
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
@@ -18,6 +20,13 @@ export const MODE_DESCRIPTIONS: Record = {
transit: 'Journey time by train, tube, and bus',
};
+export const MODE_ICONS: Record> = {
+ car: CarIcon,
+ bicycle: BicycleIcon,
+ walking: WalkingIcon,
+ transit: TransitIcon,
+};
+
export interface TravelTimeEntry {
mode: TransportMode;
slug: string;
diff --git a/frontend/src/hooks/useTutorial.ts b/frontend/src/hooks/useTutorial.ts
index 5fc4bf6..5f13d37 100644
--- a/frontend/src/hooks/useTutorial.ts
+++ b/frontend/src/hooks/useTutorial.ts
@@ -7,48 +7,48 @@ const STORAGE_KEY = 'tutorial_completed';
const STEPS: Step[] = [
{
target: '[data-tutorial="filters"]',
- title: 'Filter Properties',
+ title: 'Tell the map what matters',
content:
- 'Use filters to narrow down to areas which contain matching properties. Filter by crime rate, number of schools around, or filter to an area with detached houses. Pin a filter with the eye icon to colour the map by that feature.',
+ 'Set your budget, commute limit, school quality, crime threshold \u2014 whatever matters to you. Only areas that qualify stay lit. Use the eye icon to colour by any feature.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tutorial="ai-filters"]',
- title: 'AI-Powered Filters',
+ title: 'Or just describe it',
content:
- 'Describe your ideal area in plain English — like "quiet neighbourhood with good schools" — and AI will set up the right filters for you automatically.',
+ 'Type what you want in plain English \u2014 like "quiet area near good schools under \u00A3400k" \u2014 and we\u2019ll set up the filters for you.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tutorial="map"]',
- title: 'Explore the Map',
+ title: 'Explore what\u2019s out there',
content:
- 'Pan and zoom to explore property data across England. Click any area (hexagon or postcode boundary) to see detailed stats of historical or currently sold properties matching your filters.',
+ 'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise \u2014 everything about that neighbourhood.',
placement: 'bottom',
disableBeacon: true,
},
{
target: '[data-tutorial="search"]',
- title: 'Search Locations',
- content: 'Search for a place name or postcode to jump directly to that area on the map.',
+ title: 'Jump to a location',
+ content: 'Search for any place or postcode to fly straight there.',
placement: 'bottom',
disableBeacon: true,
},
{
target: '[data-tutorial="right-pane"]',
- title: 'Area Stats & Properties',
+ title: 'Dig into the details',
content:
- 'After clicking a hexagon, view aggregated area statistics or browse individual properties in this pane.',
+ 'See area statistics, histograms, and individual property records \u2014 prices, floor area, energy ratings, and more.',
placement: 'left',
disableBeacon: true,
},
{
target: '[data-tutorial="poi-button"]',
- title: 'Points of Interest',
+ title: 'What\u2019s nearby?',
content:
- 'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.',
+ 'Toggle schools, shops, stations, parks, and restaurants on the map to see what\u2019s within reach.',
placement: 'left',
disableBeacon: true,
styles: {
@@ -59,13 +59,13 @@ const STEPS: Step[] = [
},
];
-export function useTutorial(initialLoading: boolean, isMobile: boolean) {
+export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked = false) {
const [run, setRun] = useState(() => {
if (isMobile) return false;
return !localStorage.getItem(STORAGE_KEY);
});
- const shouldRun = run && !initialLoading && !isMobile;
+ const shouldRun = run && !initialLoading && !isMobile && !blocked;
const handleCallback = useCallback((data: CallBackProps) => {
const { status, action, type } = data;
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 3b28daa..6e6e06a 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -59,6 +59,11 @@ export async function fetchWithRetry(
}
}
+/** Fire-and-forget request to pre-warm the screenshot cache for OG images. */
+export function prewarmScreenshot(params: string): void {
+ fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
+}
+
export async function shortenUrl(params: string): Promise {
const res = await fetch(apiUrl('shorten'), {
method: 'POST',
diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts
index c0a8b5d..33f7032 100644
--- a/frontend/src/lib/clipboard.ts
+++ b/frontend/src/lib/clipboard.ts
@@ -1,7 +1,21 @@
/** Copy text to clipboard with execCommand fallback for older browsers. */
export function copyToClipboard(text: string, onSuccess: () => void): void {
if (navigator.clipboard?.writeText) {
- navigator.clipboard.writeText(text).then(onSuccess);
+ navigator.clipboard
+ .writeText(text)
+ .then(onSuccess)
+ .catch(() => {
+ // Fallback if clipboard permission denied
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ onSuccess();
+ });
} else {
const ta = document.createElement('textarea');
ta.value = text;
diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts
index 059cead..1cc1779 100644
--- a/frontend/src/lib/consts.ts
+++ b/frontend/src/lib/consts.ts
@@ -35,7 +35,7 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 13, resolution: 9 },
] as const;
-export const POSTCODE_ZOOM_THRESHOLD = 16;
+export const POSTCODE_ZOOM_THRESHOLD = 15;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] },
@@ -183,8 +183,8 @@ export const STACKED_ENUM_GROUPS: Record<
},
{
label: 'Leasehold/Freehold',
- feature: 'Leashold/Freehold',
- components: ['Leashold/Freehold'],
+ feature: 'Leasehold/Freehold',
+ components: ['Leasehold/Freehold'],
valueOrder: ['Freehold', 'Leasehold'],
valueColors: ['#3b82f6', '#f59e0b'],
},
diff --git a/frontend/src/lib/external-search.ts b/frontend/src/lib/external-search.ts
index 3ba2ba1..a86ce38 100644
--- a/frontend/src/lib/external-search.ts
+++ b/frontend/src/lib/external-search.ts
@@ -49,24 +49,56 @@ const RIGHTMOVE_PRICES = [
3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000,
];
-function nearestRadius(target: number, allowed: number[]): number {
- return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
-}
+// Rightmove allowed monthly rent values (pcm)
+const RIGHTMOVE_RENTS = [
+ 250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500,
+ 4000, 5000, 7500, 10000, 15000, 25000,
+];
-/** Snap minPrice down and maxPrice up so Rightmove doesn't ignore them */
-function snapRightmovePrice(value: number, direction: 'floor' | 'ceil'): number {
+// OnTheMarket allowed buy prices
+const OTM_PRICES = [
+ 50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000, 160000,
+ 170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 275000, 300000,
+ 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000, 600000, 650000, 700000,
+ 750000, 800000, 900000, 1000000, 1250000, 1500000, 2000000, 2500000, 3000000, 5000000, 7500000,
+ 10000000, 15000000,
+];
+
+// OnTheMarket allowed monthly rent values (pcm)
+const OTM_RENTS = [
+ 100, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000, 1100,
+ 1200, 1250, 1300, 1400, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 5000, 7500, 10000, 25000,
+];
+
+// Zoopla allowed buy prices
+const ZOOPLA_PRICES = [
+ 10000, 25000, 50000, 75000, 100000, 125000, 150000, 175000, 200000, 225000, 250000, 275000,
+ 300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000, 600000, 650000,
+ 700000, 800000, 900000, 1000000, 1250000, 1500000, 1750000, 2000000, 2500000, 3000000, 4000000,
+ 5000000, 7500000, 10000000, 15000000,
+];
+
+// Zoopla allowed monthly rent values (pcm)
+const ZOOPLA_RENTS = [
+ 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500, 4000,
+ 5000, 7500, 10000, 25000,
+];
+
+function snapToAllowed(value: number, allowed: number[], direction: 'floor' | 'ceil'): number {
if (direction === 'floor') {
- // Largest supported value <= target
- for (let i = RIGHTMOVE_PRICES.length - 1; i >= 0; i--) {
- if (RIGHTMOVE_PRICES[i] <= value) return RIGHTMOVE_PRICES[i];
+ for (let i = allowed.length - 1; i >= 0; i--) {
+ if (allowed[i] <= value) return allowed[i];
}
- return RIGHTMOVE_PRICES[0];
+ return allowed[0];
}
- // Smallest supported value >= target
- for (const p of RIGHTMOVE_PRICES) {
+ for (const p of allowed) {
if (p >= value) return p;
}
- return RIGHTMOVE_PRICES[RIGHTMOVE_PRICES.length - 1];
+ return allowed[allowed.length - 1];
+}
+
+function nearestRadius(target: number, allowed: number[]): number {
+ return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
}
interface SearchUrlOptions {
@@ -90,7 +122,19 @@ export function buildPropertySearchUrls({
const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
- const priceFilter = filters['Last known price'];
+ const listingStatus = filters['Listing status'];
+ const isRent =
+ Array.isArray(listingStatus) &&
+ typeof listingStatus[0] === 'string' &&
+ (listingStatus as string[]).includes('For rent');
+
+ // Check price filters in priority order: asking price (current listings) > estimated > last known
+ // For rent mode, check asking rent first
+ const priceFilter = isRent
+ ? filters['Asking rent (monthly)']
+ : (filters['Asking price'] ??
+ filters['Estimated current price'] ??
+ filters['Last known price']);
const minPrice =
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
const maxPrice =
@@ -131,15 +175,16 @@ export function buildPropertySearchUrls({
// Rightmove — requires locationIdentifier from typeahead API
let rightmove: string | null = null;
if (rightmoveLocationId) {
+ const rmPrices = isRent ? RIGHTMOVE_RENTS : RIGHTMOVE_PRICES;
const rmParams = new URLSearchParams();
rmParams.set('searchLocation', postcode);
rmParams.set('useLocationIdentifier', 'true');
rmParams.set('locationIdentifier', rightmoveLocationId);
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
if (minPrice !== undefined)
- rmParams.set('minPrice', String(snapRightmovePrice(minPrice, 'floor')));
+ rmParams.set('minPrice', String(snapToAllowed(minPrice, rmPrices, 'floor')));
if (maxPrice !== undefined)
- rmParams.set('maxPrice', String(snapRightmovePrice(maxPrice, 'ceil')));
+ rmParams.set('maxPrice', String(snapToAllowed(maxPrice, rmPrices, 'ceil')));
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(Math.floor(minBedrooms)));
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(Math.ceil(maxBedrooms)));
if (minBathrooms !== undefined) rmParams.set('minBathrooms', String(Math.floor(minBathrooms)));
@@ -155,20 +200,24 @@ export function buildPropertySearchUrls({
];
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
}
- if (selectedTenures.length > 0) {
+ if (!isRent && selectedTenures.length > 0) {
const rmTenures = selectedTenures.map((t) => (t === 'Freehold' ? 'FREEHOLD' : 'LEASEHOLD'));
rmParams.set('tenureTypes', rmTenures.join(','));
}
- rmParams.set('_includeSSTC', 'on');
- rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
+ if (!isRent) rmParams.set('_includeSSTC', 'on');
+ const rmPath = isRent ? 'property-to-rent' : 'property-for-sale';
+ rightmove = `https://www.rightmove.co.uk/${rmPath}/find.html?${rmParams.toString()}`;
}
// OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
+ const otmPrices = isRent ? OTM_RENTS : OTM_PRICES;
const otmParams = new URLSearchParams();
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
- if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
- if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
+ if (minPrice !== undefined)
+ otmParams.set('min-price', String(snapToAllowed(minPrice, otmPrices, 'floor')));
+ if (maxPrice !== undefined)
+ otmParams.set('max-price', String(snapToAllowed(maxPrice, otmPrices, 'ceil')));
if (selectedTypes.length > 0) {
const otmTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
@@ -178,15 +227,20 @@ export function buildPropertySearchUrls({
}
}
otmParams.set('view', 'map-list');
- const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
+ const otmPath = isRent ? 'to-rent' : 'for-sale';
+ const onthemarket = `https://www.onthemarket.com/${otmPath}/property/${otmSlug}/?${otmParams.toString()}`;
// Zoopla
+ const zPrices = isRent ? ZOOPLA_RENTS : ZOOPLA_PRICES;
const zParams = new URLSearchParams();
zParams.set('q', postcode);
- zParams.set('search_source', 'for-sale');
+ const zSearchSource = isRent ? 'to-rent' : 'for-sale';
+ zParams.set('search_source', zSearchSource);
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
- if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
- if (maxPrice !== undefined) zParams.set('price_max', String(Math.round(maxPrice)));
+ if (minPrice !== undefined)
+ zParams.set('price_min', String(snapToAllowed(minPrice, zPrices, 'floor')));
+ if (maxPrice !== undefined)
+ zParams.set('price_max', String(snapToAllowed(maxPrice, zPrices, 'ceil')));
if (selectedTypes.length > 0) {
const zTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
@@ -195,18 +249,17 @@ export function buildPropertySearchUrls({
zParams.append('property_sub_type', zt!);
}
}
- const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
+ const zoopla = `https://www.zoopla.co.uk/${zSearchSource}/property/?${zParams.toString()}`;
// OpenRent — rent mode only
- const listingStatus = filters['Listing status'];
- const isRent =
- Array.isArray(listingStatus) &&
- typeof listingStatus[0] === 'string' &&
- (listingStatus as string[]).includes('For rent');
let openrent: string | null = null;
if (isRent) {
+ const postcodeNoSpaces = postcode.replace(/\s+/g, '');
+ const orSlug = postcodeNoSpaces.toLowerCase();
const orParams = new URLSearchParams();
- orParams.set('term', postcode);
+ orParams.set('term', postcodeNoSpaces.toUpperCase());
+ const radiusKm = Math.round((isPostcode ? 0.25 : radiusMiles) * 1.609);
+ orParams.set('area', String(Math.max(1, radiusKm)));
const rentFilter = filters['Asking rent (monthly)'];
const minRent =
Array.isArray(rentFilter) && typeof rentFilter[0] === 'number' ? rentFilter[0] : undefined;
@@ -216,7 +269,7 @@ export function buildPropertySearchUrls({
if (maxRent !== undefined) orParams.set('prices_max', String(Math.round(maxRent)));
if (minBedrooms !== undefined) orParams.set('bedrooms_min', String(Math.floor(minBedrooms)));
if (maxBedrooms !== undefined) orParams.set('bedrooms_max', String(Math.ceil(maxBedrooms)));
- openrent = `https://www.openrent.com/properties-to-rent?${orParams.toString()}`;
+ openrent = `https://www.openrent.co.uk/properties-to-rent/${orSlug}?${orParams.toString()}`;
}
return { rightmove, onthemarket, zoopla, openrent };
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 1562f1c..ceda414 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -23,6 +23,26 @@ export function formatFilterValue(value: number, raw?: boolean): string {
return value.toFixed(2);
}
+/** Parse a user-typed value like "250k", "1.2M", "£300000", "50 sqm" back to a number. */
+export function parseInputValue(
+ text: string,
+ opts?: { prefix?: string; suffix?: string; step?: number }
+): number | null {
+ let s = text.trim();
+ if (opts?.prefix) s = s.replace(new RegExp(`^\\${opts.prefix}`), '');
+ if (opts?.suffix) s = s.replace(new RegExp(`${opts.suffix.trim()}$`), '');
+ s = s.trim().replace(/,/g, '');
+ const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM]?)$/);
+ if (!m) return null;
+ let val = parseFloat(m[1]);
+ if (isNaN(val)) return null;
+ const unit = m[2].toLowerCase();
+ if (unit === 'k') val *= 1_000;
+ else if (unit === 'm') val *= 1_000_000;
+ if (opts?.step) val = Math.round(val / opts.step) * opts.step;
+ return val;
+}
+
export function formatDuration(d: string): string {
if (d === 'F') return 'Freehold';
if (d === 'L') return 'Leasehold';
diff --git a/pipeline/download/lsoa_population.py b/pipeline/download/lsoa_population.py
index 1d29978..bb27c18 100644
--- a/pipeline/download/lsoa_population.py
+++ b/pipeline/download/lsoa_population.py
@@ -40,7 +40,9 @@ def download_and_convert(output_path: Path) -> None:
df = pl.concat(frames)
print(f"Total rows: {df.height}")
- result = df.rename({"GEOGRAPHY_CODE": "lsoa21", "OBS_VALUE": "population"}).with_columns(
+ result = df.rename(
+ {"GEOGRAPHY_CODE": "lsoa21", "OBS_VALUE": "population"}
+ ).with_columns(
pl.col("population").cast(pl.UInt32),
)
@@ -48,7 +50,9 @@ def download_and_convert(output_path: Path) -> None:
result = result.filter(pl.col("lsoa21").str.starts_with("E"))
print(f"England LSOAs: {result.height}")
- print(f"Population range: {result['population'].min()} - {result['population'].max()}")
+ print(
+ f"Population range: {result['population'].min()} - {result['population'].max()}"
+ )
print(f"Mean population: {result['population'].mean():.0f}")
output_path.parent.mkdir(parents=True, exist_ok=True)
diff --git a/pipeline/download/places.py b/pipeline/download/places.py
index 91ad494..bab0fdf 100644
--- a/pipeline/download/places.py
+++ b/pipeline/download/places.py
@@ -119,7 +119,11 @@ class PlaceHandler(osmium.SimpleHandler):
station_tag = tags.get("station", "")
network = tags.get("network", "").lower()
# Skip tram stops
- if station_tag == "light_rail" or "tramlink" in network or "tram" in network:
+ if (
+ station_tag == "light_rail"
+ or "tramlink" in network
+ or "tram" in network
+ ):
return
display_name = _station_display_name(name, tags)
self._add(display_name, "station", lat, lon, population)
@@ -131,9 +135,7 @@ def main() -> None:
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
)
- parser.add_argument(
- "--pbf", type=Path, required=True, help="Path to OSM PBF file"
- )
+ parser.add_argument("--pbf", type=Path, required=True, help="Path to OSM PBF file")
parser.add_argument(
"--boundary",
type=Path,
diff --git a/pipeline/download/pois.py b/pipeline/download/pois.py
index ed08b8e..d6692d6 100644
--- a/pipeline/download/pois.py
+++ b/pipeline/download/pois.py
@@ -111,9 +111,7 @@ def main() -> None:
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
)
- parser.add_argument(
- "--pbf", type=Path, required=True, help="Path to OSM PBF file"
- )
+ parser.add_argument("--pbf", type=Path, required=True, help="Path to OSM PBF file")
parser.add_argument(
"--boundary",
type=Path,
diff --git a/pipeline/download/rental_prices.py b/pipeline/download/rental_prices.py
index 9b2a1b4..31786c0 100644
--- a/pipeline/download/rental_prices.py
+++ b/pipeline/download/rental_prices.py
@@ -99,10 +99,14 @@ def convert_to_parquet(xls_path: Path, parquet_path: Path) -> None:
combined = pl.concat(frames)
# Remap old LA codes to new unitary authority codes and average medians
- combined = combined.with_columns(
- pl.col("area_code").replace(LA_CONSOLIDATION),
- ).group_by("area_code", "bedrooms").agg(
- pl.col("median_monthly_rent").mean(),
+ combined = (
+ combined.with_columns(
+ pl.col("area_code").replace(LA_CONSOLIDATION),
+ )
+ .group_by("area_code", "bedrooms")
+ .agg(
+ pl.col("median_monthly_rent").mean(),
+ )
)
print(f"Combined: {combined.shape}")
diff --git a/pipeline/download/rightmove_outcodes.py b/pipeline/download/rightmove_outcodes.py
index 0a14faf..3128549 100644
--- a/pipeline/download/rightmove_outcodes.py
+++ b/pipeline/download/rightmove_outcodes.py
@@ -13,9 +13,7 @@ TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
def fetch_outcode_ids(postcodes_path: Path, output: Path) -> None:
df = pl.read_parquet(postcodes_path, columns=["Postcode"])
- outcodes = sorted(
- set(df["Postcode"].str.split(" ").list.first().to_list()) - {""}
- )
+ outcodes = sorted(set(df["Postcode"].str.split(" ").list.first().to_list()) - {""})
print(f"Querying Rightmove typeahead for {len(outcodes)} outcodes...")
mapping: dict[str, str] = {}
@@ -28,11 +26,9 @@ def fetch_outcode_ids(postcodes_path: Path, output: Path) -> None:
data = resp.json()
found = False
for m in data.get("matches", []):
- if (
- m["type"] == "OUTCODE"
- and m["displayName"].upper().replace(" ", "")
- == oc.upper().replace(" ", "")
- ):
+ if m["type"] == "OUTCODE" and m["displayName"].upper().replace(
+ " ", ""
+ ) == oc.upper().replace(" ", ""):
mapping[oc] = str(m["id"])
found = True
break
@@ -57,9 +53,7 @@ def fetch_outcode_ids(postcodes_path: Path, output: Path) -> None:
def main() -> None:
- parser = argparse.ArgumentParser(
- description="Fetch Rightmove outcode ID mapping"
- )
+ parser = argparse.ArgumentParser(description="Fetch Rightmove outcode ID mapping")
parser.add_argument(
"--postcodes", type=Path, required=True, help="postcode.parquet path"
)
diff --git a/pipeline/download/tiles.py b/pipeline/download/tiles.py
index e0302c1..d55b094 100644
--- a/pipeline/download/tiles.py
+++ b/pipeline/download/tiles.py
@@ -64,7 +64,9 @@ def ensure_pmtiles_cli(bin_path: Path, version: str) -> None:
def main():
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--output", type=Path, required=True, help="Output .pmtiles path")
+ parser.add_argument(
+ "--output", type=Path, required=True, help="Output .pmtiles path"
+ )
parser.add_argument(
"--pmtiles-version", default="1.22.3", help="go-pmtiles release version"
)
diff --git a/pipeline/download/transit_network.py b/pipeline/download/transit_network.py
index fc62c8f..55f67ca 100644
--- a/pipeline/download/transit_network.py
+++ b/pipeline/download/transit_network.py
@@ -56,7 +56,9 @@ NR_TIMETABLE_URL = "https://opendata.nationalrail.co.uk/api/staticfeeds/3.0/time
USER_AGENT = "property-map-pipeline/1.0 (https://github.com)"
-def _download_http(url: str, dest: Path, *, desc: str, headers: dict | None = None) -> None:
+def _download_http(
+ url: str, dest: Path, *, desc: str, headers: dict | None = None
+) -> None:
"""Stream-download a URL to a file with progress bar."""
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".tmp")
@@ -117,9 +119,10 @@ def clean_gtfs(src: Path, dst: Path) -> None:
return
print("Cleaning GTFS for R5 compatibility...")
- with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(
- dst, "w", zipfile.ZIP_DEFLATED
- ) as zout:
+ with (
+ zipfile.ZipFile(src, "r") as zin,
+ zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout,
+ ):
for info in zin.infolist():
if info.filename == "stop_times.txt":
dropped = 0
@@ -127,7 +130,9 @@ def clean_gtfs(src: Path, dst: Path) -> None:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
- arr_idx = cols.index("arrival_time") if "arrival_time" in cols else -1
+ arr_idx = (
+ cols.index("arrival_time") if "arrival_time" in cols else -1
+ )
dep_idx = (
cols.index("departure_time") if "departure_time" in cols else -1
)
@@ -179,7 +184,9 @@ def clean_gtfs(src: Path, dst: Path) -> None:
year = int(date_val[:4])
if year > 2100:
parts[i] = "20991231"
- print(f" feed_info: capped end_date {date_val} → 20991231")
+ print(
+ f" feed_info: capped end_date {date_val} → 20991231"
+ )
fixed_lines.append(",".join(parts))
zout.writestr("feed_info.txt", "\n".join(fixed_lines) + "\n")
else:
@@ -334,7 +341,9 @@ def convert_high_freq_to_frequency_based(
end_secs = trips[-1][1] + int(median_hw)
headway_rounded = max(60, round(median_hw / 60) * 60)
- frequency_entries.append((template_trip_id, start_secs, end_secs, headway_rounded))
+ frequency_entries.append(
+ (template_trip_id, start_secs, end_secs, headway_rounded)
+ )
for trip_id, _ in trips[1:]:
trips_to_remove.add(trip_id)
groups_converted += 1
@@ -344,9 +353,10 @@ def convert_high_freq_to_frequency_based(
print(f" Created {len(frequency_entries)} frequency entries")
# Step 5: Write modified GTFS
- with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(
- dst, "w", zipfile.ZIP_DEFLATED
- ) as zout:
+ with (
+ zipfile.ZipFile(src, "r") as zin,
+ zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout,
+ ):
for info in zin.infolist():
if info.filename == "trips.txt":
with zin.open(info) as f:
@@ -466,15 +476,22 @@ def download_national_rail_cif(raw_dir: Path) -> Path | None:
email = os.environ.get("NATIONAL_RAIL_EMAIL")
password = os.environ.get("NATIONAL_RAIL_PASSWORD")
if not email or not password:
- print("Warning: NATIONAL_RAIL_EMAIL/NATIONAL_RAIL_PASSWORD not set, skipping national rail")
+ print(
+ "Warning: NATIONAL_RAIL_EMAIL/NATIONAL_RAIL_PASSWORD not set, skipping national rail"
+ )
return None
print("Authenticating with National Rail Open Data...")
- auth_data = urllib.parse.urlencode({"username": email, "password": password}).encode()
+ auth_data = urllib.parse.urlencode(
+ {"username": email, "password": password}
+ ).encode()
auth_req = urllib.request.Request(
NR_AUTH_URL,
data=auth_data,
- headers={"User-Agent": USER_AGENT, "Content-Type": "application/x-www-form-urlencoded"},
+ headers={
+ "User-Agent": USER_AGENT,
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
)
with urllib.request.urlopen(auth_req) as resp:
token_data = json.loads(resp.read())
@@ -565,9 +582,10 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
coords_fixed = 0
route_types_fixed = 0
- with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(
- dst, "w", zipfile.ZIP_DEFLATED
- ) as zout:
+ with (
+ zipfile.ZipFile(src, "r") as zin,
+ zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout,
+ ):
for info in zin.infolist():
# Skip non-standard links.txt
if info.filename == "links.txt":
@@ -581,8 +599,12 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
trip_id_idx = cols.index("trip_id")
stop_id_idx = cols.index("stop_id")
seq_idx = cols.index("stop_sequence")
- pickup_idx = cols.index("pickup_type") if "pickup_type" in cols else -1
- dropoff_idx = cols.index("drop_off_type") if "drop_off_type" in cols else -1
+ pickup_idx = (
+ cols.index("pickup_type") if "pickup_type" in cols else -1
+ )
+ dropoff_idx = (
+ cols.index("drop_off_type") if "drop_off_type" in cols else -1
+ )
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
@@ -769,16 +791,27 @@ def _docker_run_dtd2mysql(
) -> None:
"""Run dtd2mysql in a Node.js container on the same Docker network as MariaDB."""
cmd = [
- "docker", "run", "--rm", "--network", network,
- "-e", f"DATABASE_HOSTNAME={db_container}",
- "-e", "DATABASE_USERNAME=root",
- "-e", "DATABASE_PASSWORD=root",
- "-e", "DATABASE_NAME=dtd",
+ "docker",
+ "run",
+ "--rm",
+ "--network",
+ network,
+ "-e",
+ f"DATABASE_HOSTNAME={db_container}",
+ "-e",
+ "DATABASE_USERNAME=root",
+ "-e",
+ "DATABASE_PASSWORD=root",
+ "-e",
+ "DATABASE_NAME=dtd",
]
for v in volumes:
cmd.extend(["-v", v])
# Install zip (needed for --gtfs-zip) then run dtd2mysql
- inner = "apt-get update -qq && apt-get install -y -qq zip > /dev/null 2>&1 && npx --yes dtd2mysql " + " ".join(args)
+ inner = (
+ "apt-get update -qq && apt-get install -y -qq zip > /dev/null 2>&1 && npx --yes dtd2mysql "
+ + " ".join(args)
+ )
cmd.extend(["node:20", "bash", "-c", inner])
subprocess.run(cmd, check=True)
@@ -805,11 +838,17 @@ def convert_national_rail_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
subprocess.run(["docker", "network", "create", network], capture_output=True)
subprocess.run(
[
- "docker", "run", "-d",
- "--name", db_container,
- "--network", network,
- "-e", "MARIADB_ROOT_PASSWORD=root",
- "-e", "MARIADB_DATABASE=dtd",
+ "docker",
+ "run",
+ "-d",
+ "--name",
+ db_container,
+ "--network",
+ network,
+ "-e",
+ "MARIADB_ROOT_PASSWORD=root",
+ "-e",
+ "MARIADB_DATABASE=dtd",
"mariadb:latest",
],
check=True,
@@ -820,7 +859,16 @@ def convert_national_rail_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
print(" Waiting for MariaDB to be ready...")
for attempt in range(30):
result = subprocess.run(
- ["docker", "exec", db_container, "mariadb", "-uroot", "-proot", "-e", "SELECT 1"],
+ [
+ "docker",
+ "exec",
+ db_container,
+ "mariadb",
+ "-uroot",
+ "-proot",
+ "-e",
+ "SELECT 1",
+ ],
capture_output=True,
)
if result.returncode == 0:
@@ -833,14 +881,16 @@ def convert_national_rail_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
print("Importing CIF timetable into MariaDB...")
_docker_run_dtd2mysql(
- network, db_container,
+ network,
+ db_container,
volumes=[f"{raw_abs}:/data:ro"],
args=["--timetable", "/data/national_rail_cif.zip"],
)
print("Exporting GTFS from MariaDB...")
_docker_run_dtd2mysql(
- network, db_container,
+ network,
+ db_container,
volumes=[f"{raw_abs}:/output"],
args=["--gtfs-zip", "/output/national_rail_gtfs_raw.zip"],
)
diff --git a/pipeline/transform/merge.py b/pipeline/transform/merge.py
index 23ff806..b691760 100644
--- a/pipeline/transform/merge.py
+++ b/pipeline/transform/merge.py
@@ -94,11 +94,18 @@ def _build(
# Remap terminated postcodes to nearest active successor
postcode_mapping = build_postcode_mapping(arcgis_path)
- wide = wide.join(
- postcode_mapping.lazy(), left_on="postcode", right_on="old_postcode", how="left"
- ).with_columns(
- pl.coalesce("new_postcode", "postcode").alias("postcode"),
- ).drop("new_postcode")
+ wide = (
+ wide.join(
+ postcode_mapping.lazy(),
+ left_on="postcode",
+ right_on="old_postcode",
+ how="left",
+ )
+ .with_columns(
+ pl.coalesce("new_postcode", "postcode").alias("postcode"),
+ )
+ .drop("new_postcode")
+ )
arcgis = (
pl.scan_parquet(arcgis_path)
@@ -179,11 +186,11 @@ def _build(
lsoa_pop = pl.scan_parquet(lsoa_population_path)
wide = wide.join(lsoa_pop, on="lsoa21", how="left")
wide = wide.with_columns(
- (pl.col("serious_crime_avg_yr") / pl.col("population") * 1000)
- .round(1)
+ pl.when(pl.col("population") > 0)
+ .then((pl.col("serious_crime_avg_yr") / pl.col("population") * 1000).round(1))
.alias("serious_crime_per_1k"),
- (pl.col("minor_crime_avg_yr") / pl.col("population") * 1000)
- .round(1)
+ pl.when(pl.col("population") > 0)
+ .then((pl.col("minor_crime_avg_yr") / pl.col("population") * 1000).round(1))
.alias("minor_crime_per_1k"),
).drop("population")
@@ -252,16 +259,18 @@ def _build(
.otherwise(pl.col("pp_property_type"))
# Unify EPC's "Flat"/"Maisonette" with price-paid's "Flats/Maisonettes",
# collapse terrace sub-types, and fold rare types into "Other"
- .replace({
- "Flat": "Flats/Maisonettes",
- "Maisonette": "Flats/Maisonettes",
- "End-Terrace": "Terraced",
- "Mid-Terrace": "Terraced",
- "Enclosed End-Terrace": "Terraced",
- "Enclosed Mid-Terrace": "Terraced",
- "Bungalow": "Other",
- "Park home": "Other",
- })
+ .replace(
+ {
+ "Flat": "Flats/Maisonettes",
+ "Maisonette": "Flats/Maisonettes",
+ "End-Terrace": "Terraced",
+ "Mid-Terrace": "Terraced",
+ "Enclosed End-Terrace": "Terraced",
+ "Enclosed Mid-Terrace": "Terraced",
+ "Bungalow": "Other",
+ "Park home": "Other",
+ }
+ )
.alias("property_type")
)
@@ -426,10 +435,16 @@ def main():
help="Census 2021 population by LSOA parquet file",
)
parser.add_argument(
- "--output-postcodes", type=Path, required=True, help="Output postcode parquet file path"
+ "--output-postcodes",
+ type=Path,
+ required=True,
+ help="Output postcode parquet file path",
)
parser.add_argument(
- "--output-properties", type=Path, required=True, help="Output properties parquet file path"
+ "--output-properties",
+ type=Path,
+ required=True,
+ help="Output properties parquet file path",
)
args = parser.parse_args()
diff --git a/pipeline/transform/postcode_boundaries/test_postcode_boundaries.py b/pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
index 6e1330c..af5a9d9 100644
--- a/pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
+++ b/pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
@@ -454,9 +454,7 @@ class TestFillHoles:
hole1 = [(10, 10), (20, 10), (20, 20), (10, 20), (10, 10)]
outer2 = [(60, 60), (110, 60), (110, 110), (60, 110), (60, 60)]
hole2 = [(70, 70), (80, 70), (80, 80), (70, 80), (70, 70)]
- mp = MultiPolygon(
- [Polygon(outer1, [hole1]), Polygon(outer2, [hole2])]
- )
+ mp = MultiPolygon([Polygon(outer1, [hole1]), Polygon(outer2, [hole2])])
result = _fill_holes(mp)
assert result.geom_type == "MultiPolygon"
for p in result.geoms:
diff --git a/pipeline/transform/price_estimation/backtest.py b/pipeline/transform/price_estimation/backtest.py
index f0075db..8f88792 100644
--- a/pipeline/transform/price_estimation/backtest.py
+++ b/pipeline/transform/price_estimation/backtest.py
@@ -112,7 +112,9 @@ def predict(test: pl.DataFrame, index: pl.DataFrame) -> pl.DataFrame:
def compute_metrics(actual: np.ndarray, predicted: np.ndarray) -> dict:
- valid = np.isfinite(predicted) & np.isfinite(actual) & (actual > 0) & (predicted > 0)
+ valid = (
+ np.isfinite(predicted) & np.isfinite(actual) & (actual > 0) & (predicted > 0)
+ )
actual = actual[valid]
predicted = predicted[valid]
@@ -176,7 +178,10 @@ def main():
"--input", type=Path, required=True, help="Path to properties.parquet"
)
parser.add_argument(
- "--postcodes", type=Path, required=True, help="Path to postcode.parquet (for lat/lon)"
+ "--postcodes",
+ type=Path,
+ required=True,
+ help="Path to postcode.parquet (for lat/lon)",
)
parser.add_argument(
"--output", type=Path, required=True, help="Output backtest_results.parquet"
@@ -185,7 +190,9 @@ def main():
# Build index from pre-test data only (temporal holdout)
print(f"Building price index (pairs with year2 < {TEST_YEAR_MIN})...")
- index = build_index(args.input, max_pair_year=TEST_YEAR_MIN, postcodes_path=args.postcodes)
+ index = build_index(
+ args.input, max_pair_year=TEST_YEAR_MIN, postcodes_path=args.postcodes
+ )
print(
f"\nHoldout index: {len(index):,} rows, {index['sector'].n_unique():,} sectors, "
f"{index['type_group'].n_unique()} type groups"
@@ -233,7 +240,9 @@ def main():
knn_est = knn_psm * fa * temporal_adj
n_knn = int((np.isfinite(knn_est) & (knn_est > 0)).sum())
- print(f" kNN estimates: {n_knn:,} of {len(test):,} ({n_knn / len(test) * 100:.1f}%)")
+ print(
+ f" kNN estimates: {n_knn:,} of {len(test):,} ({n_knn / len(test) * 100:.1f}%)"
+ )
# Blend: (1-w)*index + w*kNN where both available
index_est = test["predicted"].to_numpy().astype(np.float64)
diff --git a/pipeline/transform/price_estimation/estimate.py b/pipeline/transform/price_estimation/estimate.py
index 72e9401..8827278 100644
--- a/pipeline/transform/price_estimation/estimate.py
+++ b/pipeline/transform/price_estimation/estimate.py
@@ -107,9 +107,7 @@ def main():
pl.when(has_price)
.then(
pl.col("Last known price").cast(pl.Float64)
- * (
- pl.col("_log_index_current_interp") - pl.col("_log_index_sale_interp")
- )
+ * (pl.col("_log_index_current_interp") - pl.col("_log_index_sale_interp"))
.clip(-MAX_LOG_ADJUSTMENT, MAX_LOG_ADJUSTMENT)
.exp()
)
diff --git a/pipeline/transform/price_estimation/index.py b/pipeline/transform/price_estimation/index.py
index e01cb45..a07bc0b 100644
--- a/pipeline/transform/price_estimation/index.py
+++ b/pipeline/transform/price_estimation/index.py
@@ -105,9 +105,7 @@ def extract_pairs(input_path: Path, max_year2: int | None = None) -> pl.DataFram
.alias("log_ratio"),
(
1.0
- / (pl.col("frac_year2") - pl.col("frac_year1"))
- .cast(pl.Float64)
- .sqrt()
+ / (pl.col("frac_year2") - pl.col("frac_year1")).cast(pl.Float64).sqrt()
).alias("weight"),
)
.filter(pl.col("log_ratio").abs() <= OUTLIER_THRESHOLD)
@@ -453,8 +451,12 @@ def main():
description="Build improved repeat-sales price index"
)
parser.add_argument("--input", type=Path, required=True)
- parser.add_argument("--postcodes", type=Path, required=True,
- help="Path to postcode.parquet (for lat/lon centroids)")
+ parser.add_argument(
+ "--postcodes",
+ type=Path,
+ required=True,
+ help="Path to postcode.parquet (for lat/lon centroids)",
+ )
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
diff --git a/pipeline/transform/price_estimation/knn.py b/pipeline/transform/price_estimation/knn.py
index 061b188..6c4367f 100644
--- a/pipeline/transform/price_estimation/knn.py
+++ b/pipeline/transform/price_estimation/knn.py
@@ -43,48 +43,39 @@ def build_knn_pool(
"""
print("Building kNN pool...")
lf = pl.scan_parquet(source) if isinstance(source, Path) else source
- query = (
- lf
- .select(
- "Postcode",
- "Property type",
- "lat",
- "lon",
- "Total floor area (sqm)",
- "Last known price",
- "Date of last transaction",
- )
- .filter(
- pl.col("lat").is_not_null(),
- pl.col("lon").is_not_null(),
- pl.col("Total floor area (sqm)").is_not_null(),
- pl.col("Total floor area (sqm)") > 0,
- pl.col("Last known price").is_not_null(),
- pl.col("Last known price") > 0,
- pl.col("Postcode").is_not_null(),
- pl.col("Date of last transaction").is_not_null(),
- )
+ query = lf.select(
+ "Postcode",
+ "Property type",
+ "lat",
+ "lon",
+ "Total floor area (sqm)",
+ "Last known price",
+ "Date of last transaction",
+ ).filter(
+ pl.col("lat").is_not_null(),
+ pl.col("lon").is_not_null(),
+ pl.col("Total floor area (sqm)").is_not_null(),
+ pl.col("Total floor area (sqm)") > 0,
+ pl.col("Last known price").is_not_null(),
+ pl.col("Last known price") > 0,
+ pl.col("Postcode").is_not_null(),
+ pl.col("Date of last transaction").is_not_null(),
)
if max_sale_year is not None:
query = query.filter(
pl.col("Date of last transaction").dt.year() < max_sale_year
)
- pool = (
- query.with_columns(
- sector_expr(),
- type_group_expr(),
- (
- pl.col("Date of last transaction").dt.year().cast(pl.Float64)
- + (
- pl.col("Date of last transaction").dt.month().cast(pl.Float64)
- - 1.0
- )
- / 12.0
- ).alias("_sale_fy"),
- pl.lit(ref_frac_year).alias("_ref_fy"),
- ).collect()
- )
+ pool = query.with_columns(
+ sector_expr(),
+ type_group_expr(),
+ (
+ pl.col("Date of last transaction").dt.year().cast(pl.Float64)
+ + (pl.col("Date of last transaction").dt.month().cast(pl.Float64) - 1.0)
+ / 12.0
+ ).alias("_sale_fy"),
+ pl.lit(ref_frac_year).alias("_ref_fy"),
+ ).collect()
pool = pool.filter(pl.col("type_group").is_not_null())
print(f" {len(pool):,} pool properties with lat/lon, floor area, price")
diff --git a/pipeline/transform/transform_poi.py b/pipeline/transform/transform_poi.py
index 47dfdfc..5add432 100644
--- a/pipeline/transform/transform_poi.py
+++ b/pipeline/transform/transform_poi.py
@@ -1085,7 +1085,9 @@ def transform(
if cat not in all_set:
mapped_but_absent.append(cat)
if mapped_but_absent:
- print(f"CATEGORY_MAP categories not in data (skipped): {sorted(mapped_but_absent)}")
+ print(
+ f"CATEGORY_MAP categories not in data (skipped): {sorted(mapped_but_absent)}"
+ )
# Drop unwanted categories
lf = lf.filter(~pl.col("category").is_in(list(DROP_CATEGORIES)))
diff --git a/pipeline/utils/download.py b/pipeline/utils/download.py
index 558184f..d889c89 100644
--- a/pipeline/utils/download.py
+++ b/pipeline/utils/download.py
@@ -37,4 +37,4 @@ def extract_zip(zip_path: Path, extract_dir: Path) -> None:
"""Extract a ZIP archive into the given directory."""
extract_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, "r") as zf:
- zf.extractall(extract_dir)
+ zf.extractall(extract_dir, filter="data")
diff --git a/pipeline/utils/england_geometry.py b/pipeline/utils/england_geometry.py
index 762de3b..218bc56 100644
--- a/pipeline/utils/england_geometry.py
+++ b/pipeline/utils/england_geometry.py
@@ -27,7 +27,9 @@ def load_england_polygon(geojson_path: Path) -> PreparedGeometry:
return prep(geometry)
-def in_england_mask(geojson_path: Path, lats: np.ndarray, lngs: np.ndarray) -> np.ndarray:
+def in_england_mask(
+ geojson_path: Path, lats: np.ndarray, lngs: np.ndarray
+) -> np.ndarray:
"""Vectorized check: which (lat, lng) points are within England.
Returns a boolean numpy array.
diff --git a/pipeline/utils/fuzzy_join.py b/pipeline/utils/fuzzy_join.py
index 9ca8e4a..5fa6cc8 100644
--- a/pipeline/utils/fuzzy_join.py
+++ b/pipeline/utils/fuzzy_join.py
@@ -84,7 +84,7 @@ def fuzzy_join_on_postcode(
right_match["_right_postcode"],
right_match["_right_address"],
):
- if postcode is not None:
+ if address is not None and postcode is not None:
right_by_postcode.setdefault(postcode, []).append((idx, address))
# Group left side by postcode
diff --git a/pipeline/utils/poi_counts.py b/pipeline/utils/poi_counts.py
index bafbfa3..8685d66 100644
--- a/pipeline/utils/poi_counts.py
+++ b/pipeline/utils/poi_counts.py
@@ -106,7 +106,9 @@ def count_pois_per_postcode(
if nearby is None:
continue
- distances = haversine_km(poi_lats[nearby], poi_lngs[nearby], pc_lats[i], pc_lons[i])
+ distances = haversine_km(
+ poi_lats[nearby], poi_lngs[nearby], pc_lats[i], pc_lons[i]
+ )
within_mask = distances <= radius_km
within_indices = nearby[within_mask]
@@ -179,7 +181,9 @@ def min_distance_per_postcode(
if nearby is None:
continue
- distances = haversine_km(poi_lats[nearby], poi_lngs[nearby], pc_lats[i], pc_lons[i])
+ distances = haversine_km(
+ poi_lats[nearby], poi_lngs[nearby], pc_lats[i], pc_lons[i]
+ )
for group, cat_mask in category_masks.items():
group_mask = cat_mask[nearby]
diff --git a/pipeline/utils/postcode_mapping.py b/pipeline/utils/postcode_mapping.py
index ca27e01..543db02 100644
--- a/pipeline/utils/postcode_mapping.py
+++ b/pipeline/utils/postcode_mapping.py
@@ -15,26 +15,49 @@ def build_postcode_mapping(arcgis_path: Path) -> pl.DataFrame:
"""
arcgis = pl.scan_parquet(arcgis_path).filter(pl.col("ctry") == "E92000001")
- active = arcgis.filter(pl.col("doterm").is_null()).select("pcds", "oseast1m", "osnrth1m").collect()
- terminated = arcgis.filter(pl.col("doterm").is_not_null()).select("pcds", "oseast1m", "osnrth1m").collect()
+ active = (
+ arcgis.filter(pl.col("doterm").is_null())
+ .select("pcds", "oseast1m", "osnrth1m")
+ .collect()
+ )
+ terminated = (
+ arcgis.filter(pl.col("doterm").is_not_null())
+ .select("pcds", "oseast1m", "osnrth1m")
+ .collect()
+ )
- print(f"Active postcodes: {active.height}, terminated postcodes: {terminated.height}")
+ print(
+ f"Active postcodes: {active.height}, terminated postcodes: {terminated.height}"
+ )
if terminated.height == 0:
- return pl.DataFrame({"old_postcode": pl.Series([], dtype=pl.Utf8), "new_postcode": pl.Series([], dtype=pl.Utf8)})
+ return pl.DataFrame(
+ {
+ "old_postcode": pl.Series([], dtype=pl.Utf8),
+ "new_postcode": pl.Series([], dtype=pl.Utf8),
+ }
+ )
- active_coords = np.column_stack([active["oseast1m"].to_numpy(), active["osnrth1m"].to_numpy()])
- terminated_coords = np.column_stack([terminated["oseast1m"].to_numpy(), terminated["osnrth1m"].to_numpy()])
+ active_coords = np.column_stack(
+ [active["oseast1m"].to_numpy(), active["osnrth1m"].to_numpy()]
+ )
+ terminated_coords = np.column_stack(
+ [terminated["oseast1m"].to_numpy(), terminated["osnrth1m"].to_numpy()]
+ )
tree = cKDTree(active_coords)
distances, indices = tree.query(terminated_coords)
active_postcodes = active["pcds"]
- mapping = pl.DataFrame({
- "old_postcode": terminated["pcds"],
- "new_postcode": active_postcodes.gather(indices),
- })
+ mapping = pl.DataFrame(
+ {
+ "old_postcode": terminated["pcds"],
+ "new_postcode": active_postcodes.gather(indices),
+ }
+ )
- print(f"Postcode mapping: max distance = {distances.max():.0f}m, median = {np.median(distances):.0f}m")
+ print(
+ f"Postcode mapping: max distance = {distances.max():.0f}m, median = {np.median(distances):.0f}m"
+ )
return mapping
diff --git a/pipeline/utils/test_poi_counts.py b/pipeline/utils/test_poi_counts.py
index 4c4f832..134a716 100644
--- a/pipeline/utils/test_poi_counts.py
+++ b/pipeline/utils/test_poi_counts.py
@@ -72,7 +72,9 @@ def test_no_pois_returns_zeros(postcodes):
"category": pl.Series([], dtype=pl.String),
}
)
- result = count_pois_per_postcode(postcodes, empty_pois, groups=POI_GROUPS, radius_km=2.0)
+ result = count_pois_per_postcode(
+ postcodes, empty_pois, groups=POI_GROUPS, radius_km=2.0
+ )
for group in POI_GROUPS:
col = f"{group}_2km"
@@ -125,7 +127,9 @@ def test_min_distance_no_pois_returns_nan(postcodes):
"category": pl.Series([], dtype=pl.String),
}
)
- result = min_distance_per_postcode(postcodes, empty_pois, groups={"train_tube": ["Rail station"]})
+ result = min_distance_per_postcode(
+ postcodes, empty_pois, groups={"train_tube": ["Rail station"]}
+ )
assert "train_tube_nearest_km" in result.columns
assert all(np.isnan(v) for v in result["train_tube_nearest_km"].to_list())
diff --git a/price_model.ipynb b/price_model.ipynb
index 8424381..9a84d0d 100644
--- a/price_model.ipynb
+++ b/price_model.ipynb
@@ -28,8 +28,8 @@
"import matplotlib.pyplot as plt\n",
"\n",
"\n",
- "pd.set_option('display.max_columns', None)\n",
- "pd.set_option('display.max_colwidth', 60)"
+ "pd.set_option(\"display.max_columns\", None)\n",
+ "pd.set_option(\"display.max_colwidth\", 60)"
]
},
{
@@ -47,7 +47,7 @@
"metadata": {},
"outputs": [],
"source": [
- "param_import_path = '/bulk/wide-2.parquet'\n",
+ "param_import_path = \"/bulk/wide-2.parquet\"\n",
"\n",
"param_lookback = 3"
]
@@ -128,7 +128,7 @@
],
"source": [
"data = pl.scan_parquet(param_import_path).unique(subset=[\"Postcode\", \"Address per EPC\"])\n",
- "data = data.filter(pl.col('Total floor area (sqm)') > 10)\n",
+ "data = data.filter(pl.col(\"Total floor area (sqm)\") > 10)\n",
"\n",
"# print(data.collect_schema()) # column names and types\n",
"print(data.select(pl.len()).collect()) # row count\n",
@@ -145,22 +145,20 @@
"outputs": [],
"source": [
"columns_required = [\n",
- " # absolute neccesity \n",
- " 'Postcode',\n",
- " 'Address per EPC',\n",
- " 'historical_prices',\n",
- " 'Price per sqm',\n",
- "\n",
+ " # absolute neccesity\n",
+ " \"Postcode\",\n",
+ " \"Address per EPC\",\n",
+ " \"historical_prices\",\n",
+ " \"Price per sqm\",\n",
" # faily fixed attributes\n",
- " 'Property type', # or 'epc_property_type' or 'built_form'\n",
- " 'Leashold/Freehold',\n",
- " 'Total floor area (sqm)',\n",
- " 'Rooms (including bedrooms & bathrooms)',\n",
- " 'Approximate construction age',\n",
- "\n",
+ " \"Property type\", # or 'epc_property_type' or 'built_form'\n",
+ " \"Leashold/Freehold\",\n",
+ " \"Total floor area (sqm)\",\n",
+ " \"Rooms (including bedrooms & bathrooms)\",\n",
+ " \"Approximate construction age\",\n",
" # latest\n",
" # 'date_of_transfer'\n",
- " 'Last known price'\n",
+ " \"Last known price\",\n",
"]"
]
},
@@ -440,8 +438,13 @@
],
"source": [
"# temp_Postcodes = [\"LE5 4ED\", \"E14 9GU\", \"YO8 9PW\", \"SW1P 3AN\", \"BH3 7DX\", \"E14 2DG\"]\n",
- "temp_Postcodes = data.select('Postcode').collect().sample(10000)['Postcode'].to_list()\n",
- "data_small = data.filter(pl.col(\"Postcode\").is_in(temp_Postcodes)).select(columns_required).collect().to_pandas()\n",
+ "temp_Postcodes = data.select(\"Postcode\").collect().sample(10000)[\"Postcode\"].to_list()\n",
+ "data_small = (\n",
+ " data.filter(pl.col(\"Postcode\").is_in(temp_Postcodes))\n",
+ " .select(columns_required)\n",
+ " .collect()\n",
+ " .to_pandas()\n",
+ ")\n",
"data_small = data_small.explode(\"historical_prices\")\n",
"data_small[\"year\"] = data_small[\"historical_prices\"].apply(lambda x: x[\"year\"])\n",
"data_small[\"price\"] = data_small[\"historical_prices\"].apply(lambda x: x[\"price\"])\n",
@@ -458,7 +461,7 @@
"outputs": [],
"source": [
"# data_small[\n",
- "# (data_small['Postcode'] == 'E14 2DG') \n",
+ "# (data_small['Postcode'] == 'E14 2DG')\n",
"# & data_small['epc_address'].str.contains('76')\n",
"# ]"
]
@@ -908,35 +911,45 @@
"from typing import Any\n",
"from pandas.core.frame import DataFrame\n",
"\n",
- "print(f'rolling periods (relative): {[i for i in range(-param_lookback, 1)]}')\n",
+ "print(f\"rolling periods (relative): {[i for i in range(-param_lookback, 1)]}\")\n",
"\n",
"# Rolling average (±2 year), weighted by number of sales per year\n",
- "pc_avg_raw = data_small.groupby(['Postcode', 'year']).agg(\n",
- " ppsqm_sum=('Price per sqm', 'sum'),\n",
- " ppsqm_count=('Price per sqm', 'count')\n",
- ").reset_index().sort_values(by=['Postcode', 'year'], ascending=False)\n",
+ "pc_avg_raw = (\n",
+ " data_small.groupby([\"Postcode\", \"year\"])\n",
+ " .agg(ppsqm_sum=(\"Price per sqm\", \"sum\"), ppsqm_count=(\"Price per sqm\", \"count\"))\n",
+ " .reset_index()\n",
+ " .sort_values(by=[\"Postcode\", \"year\"], ascending=False)\n",
+ ")\n",
"\n",
"display(pc_avg_raw)\n",
"\n",
"# Each year's totals contribute to year-1, year, and year+1\n",
- "pc_avg_expanded = pd.concat([\n",
- " pc_avg_raw.assign(year=pc_avg_raw['year'] + offset) for offset in range(-param_lookback, 1) # \n",
- "])\n",
+ "pc_avg_expanded = pd.concat(\n",
+ " [\n",
+ " pc_avg_raw.assign(year=pc_avg_raw[\"year\"] + offset)\n",
+ " for offset in range(-param_lookback, 1) #\n",
+ " ]\n",
+ ")\n",
"\n",
"display(pc_avg_expanded)\n",
"\n",
"# Sum counts and sums, then divide to get weighted mean\n",
- "pc_avg_complex = pc_avg_expanded.groupby(['Postcode', 'year']).agg(\n",
- " ppsqm_sum=('ppsqm_sum', 'sum'),\n",
- " ppsqm_count=('ppsqm_count', 'sum')\n",
- ").reset_index()\n",
- "pc_avg_complex['Price per sqm PC AVG'] = pc_avg_complex['ppsqm_sum'] / pc_avg_complex['ppsqm_count']\n",
- "pc_avg_complex: Any | DataFrame = pc_avg_complex[['Postcode', 'year', 'Price per sqm PC AVG']].sort_values(by=['Postcode', 'year'], ascending=False)\n",
+ "pc_avg_complex = (\n",
+ " pc_avg_expanded.groupby([\"Postcode\", \"year\"])\n",
+ " .agg(ppsqm_sum=(\"ppsqm_sum\", \"sum\"), ppsqm_count=(\"ppsqm_count\", \"sum\"))\n",
+ " .reset_index()\n",
+ ")\n",
+ "pc_avg_complex[\"Price per sqm PC AVG\"] = (\n",
+ " pc_avg_complex[\"ppsqm_sum\"] / pc_avg_complex[\"ppsqm_count\"]\n",
+ ")\n",
+ "pc_avg_complex: Any | DataFrame = pc_avg_complex[\n",
+ " [\"Postcode\", \"year\", \"Price per sqm PC AVG\"]\n",
+ "].sort_values(by=[\"Postcode\", \"year\"], ascending=False)\n",
"display(pc_avg_complex)\n",
"\n",
- "temp_df = pc_avg_complex[pc_avg_complex['Postcode'] == data_small['Postcode'].iloc[0]]\n",
- "print(data_small['Postcode'].iloc[0])\n",
- "temp_df.plot.line(x='year', y='Price per sqm PC AVG')"
+ "temp_df = pc_avg_complex[pc_avg_complex[\"Postcode\"] == data_small[\"Postcode\"].iloc[0]]\n",
+ "print(data_small[\"Postcode\"].iloc[0])\n",
+ "temp_df.plot.line(x=\"year\", y=\"Price per sqm PC AVG\")"
]
},
{
@@ -1111,9 +1124,13 @@
}
],
"source": [
- "data_small = data_small.merge(pc_avg_complex, on=['Postcode', 'year'], suffixes=('', ' pc_avg_complex'))\n",
- "data_small['c'] = data_small['Price per sqm'] / data_small['Price per sqm PC AVG']\n",
- "data_small[['Postcode', 'Address per EPC', 'Price per sqm', 'Price per sqm PC AVG', 'c']]"
+ "data_small = data_small.merge(\n",
+ " pc_avg_complex, on=[\"Postcode\", \"year\"], suffixes=(\"\", \" pc_avg_complex\")\n",
+ ")\n",
+ "data_small[\"c\"] = data_small[\"Price per sqm\"] / data_small[\"Price per sqm PC AVG\"]\n",
+ "data_small[\n",
+ " [\"Postcode\", \"Address per EPC\", \"Price per sqm\", \"Price per sqm PC AVG\", \"c\"]\n",
+ "]"
]
},
{
@@ -1445,17 +1462,21 @@
],
"source": [
"# 1. Coefficient of Variation (std/mean) per property, filtered to 3+ sales\n",
- "c_stats = data_small.groupby(['Postcode', 'Address per EPC']).agg(\n",
- " n_sales=('c', 'count'),\n",
- " year_min=('year', 'min'),\n",
- " year_max=('year', 'max'),\n",
- " c_mean=('c', 'mean'),\n",
- " c_std=('c', 'std'),\n",
- ").dropna()\n",
- "c_stats['c_cv'] = c_stats['c_std'] / c_stats['c_mean']\n",
+ "c_stats = (\n",
+ " data_small.groupby([\"Postcode\", \"Address per EPC\"])\n",
+ " .agg(\n",
+ " n_sales=(\"c\", \"count\"),\n",
+ " year_min=(\"year\", \"min\"),\n",
+ " year_max=(\"year\", \"max\"),\n",
+ " c_mean=(\"c\", \"mean\"),\n",
+ " c_std=(\"c\", \"std\"),\n",
+ " )\n",
+ " .dropna()\n",
+ ")\n",
+ "c_stats[\"c_cv\"] = c_stats[\"c_std\"] / c_stats[\"c_mean\"]\n",
"# c_stats_3plus = c_stats[c_stats['n_sales'] >= 3]\n",
"# print(f\"Properties with 3+ sales: {len(c_stats_3plus)} / {len(c_stats)}\")\n",
- "c_stats.sort_values('c_cv', ascending=False).head(20)"
+ "c_stats.sort_values(\"c_cv\", ascending=False).head(20)"
]
},
{
@@ -2265,42 +2286,44 @@
"display(random_c)\n",
"\n",
"# pc avg trend\n",
- "temp_pc_avg = pc_avg_complex[pc_avg_complex['Postcode'] == random_c.index[0][0]].sort_values(by='year')\n",
+ "temp_pc_avg = pc_avg_complex[\n",
+ " pc_avg_complex[\"Postcode\"] == random_c.index[0][0]\n",
+ "].sort_values(by=\"year\")\n",
"display(temp_pc_avg)\n",
"\n",
"# c for specific address\n",
"temp_postcode = data_small[\n",
- " (data_small['Postcode'] == random_c.index[0][0]) \n",
- " # & (data_small['Address per EPC'] == random_c.index[0][1]) \n",
- "].sort_values(by='year')\n",
+ " (data_small[\"Postcode\"] == random_c.index[0][0])\n",
+ " # & (data_small['Address per EPC'] == random_c.index[0][1])\n",
+ "].sort_values(by=\"year\")\n",
"display(temp_postcode)\n",
"\n",
"temp_address = data_small[\n",
- " (data_small['Postcode'] == random_c.index[0][0]) \n",
- " & (data_small['Address per EPC'] == random_c.index[0][1]) \n",
- "].sort_values(by='year')\n",
+ " (data_small[\"Postcode\"] == random_c.index[0][0])\n",
+ " & (data_small[\"Address per EPC\"] == random_c.index[0][1])\n",
+ "].sort_values(by=\"year\")\n",
"display(temp_address)\n",
"\n",
"# plot\n",
"\n",
"fig, ax1 = plt.subplots()\n",
"\n",
- "temp_pc_avg.plot.line(x='year', y='Price per sqm PC AVG', ax=ax1, color='black')\n",
- "temp_address.plot.line(x='year', y='Price per sqm', ax=ax1, color='green') \n",
+ "temp_pc_avg.plot.line(x=\"year\", y=\"Price per sqm PC AVG\", ax=ax1, color=\"black\")\n",
+ "temp_address.plot.line(x=\"year\", y=\"Price per sqm\", ax=ax1, color=\"green\")\n",
"\n",
"ax2 = ax1.twinx()\n",
"ax2.set_ylim(0, 3)\n",
"\n",
- "for property in temp_postcode['Address per EPC'].unique():\n",
- " property_data = temp_postcode[temp_postcode['Address per EPC'] == property]\n",
- " property_data.plot.line(x='year', y='c', ax=ax2, color='orange', style=':')\n",
+ "for property in temp_postcode[\"Address per EPC\"].unique():\n",
+ " property_data = temp_postcode[temp_postcode[\"Address per EPC\"] == property]\n",
+ " property_data.plot.line(x=\"year\", y=\"c\", ax=ax2, color=\"orange\", style=\":\")\n",
"\n",
- "temp_address.plot.line(x='year', y='c', ax=ax2, color='red', style=':')\n",
+ "temp_address.plot.line(x=\"year\", y=\"c\", ax=ax2, color=\"red\", style=\":\")\n",
"\n",
- "ax1.set_ylabel('Price per sqm')\n",
- "ax2.set_ylabel('c')\n",
+ "ax1.set_ylabel(\"Price per sqm\")\n",
+ "ax2.set_ylabel(\"c\")\n",
"\n",
- "plt.show()\n"
+ "plt.show()"
]
},
{
@@ -2640,17 +2663,21 @@
],
"source": [
"# 1. Coefficient of Variation (std/mean) per property, filtered to 3+ sales\n",
- "c_stats = data_small.groupby(['Postcode', 'Address per EPC']).agg(\n",
- " n_sales=('c', 'count'),\n",
- " year_min=('year', 'min'),\n",
- " year_max=('year', 'max'),\n",
- " c_mean=('c', 'mean'),\n",
- " c_std=('c', 'std'),\n",
- ").dropna()\n",
- "c_stats['c_cv'] = c_stats['c_std'] / c_stats['c_mean']\n",
+ "c_stats = (\n",
+ " data_small.groupby([\"Postcode\", \"Address per EPC\"])\n",
+ " .agg(\n",
+ " n_sales=(\"c\", \"count\"),\n",
+ " year_min=(\"year\", \"min\"),\n",
+ " year_max=(\"year\", \"max\"),\n",
+ " c_mean=(\"c\", \"mean\"),\n",
+ " c_std=(\"c\", \"std\"),\n",
+ " )\n",
+ " .dropna()\n",
+ ")\n",
+ "c_stats[\"c_cv\"] = c_stats[\"c_std\"] / c_stats[\"c_mean\"]\n",
"# c_stats_3plus = c_stats[c_stats['n_sales'] >= 3]\n",
"# print(f\"Properties with 3+ sales: {len(c_stats_3plus)} / {len(c_stats)}\")\n",
- "c_stats.sort_values('c_cv', ascending=False).head(20)"
+ "c_stats.sort_values(\"c_cv\", ascending=False).head(20)"
]
},
{
@@ -2685,31 +2712,41 @@
"\n",
"fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n",
"\n",
- "axes[0].hist(c_stats['c_std'], bins=100, edgecolor='black')\n",
- "axes[0].set_xlabel('Std of c')\n",
- "axes[0].set_ylabel('Number of properties')\n",
- "axes[0].set_title('Distribution of c stability (std)')\n",
- "axes[0].axvline(x=c_stats['c_std'].median(), color='red', linestyle='--', label=f'Median ({c_stats['c_std'].median()}) threshold')\n",
+ "axes[0].hist(c_stats[\"c_std\"], bins=100, edgecolor=\"black\")\n",
+ "axes[0].set_xlabel(\"Std of c\")\n",
+ "axes[0].set_ylabel(\"Number of properties\")\n",
+ "axes[0].set_title(\"Distribution of c stability (std)\")\n",
+ "axes[0].axvline(\n",
+ " x=c_stats[\"c_std\"].median(),\n",
+ " color=\"red\",\n",
+ " linestyle=\"--\",\n",
+ " label=f\"Median ({c_stats['c_std'].median()}) threshold\",\n",
+ ")\n",
"axes[0].legend()\n",
"\n",
- "axes[1].hist(c_stats['c_cv'], bins=100, edgecolor='black')\n",
- "axes[1].set_xlabel('CV of c (std/mean)')\n",
- "axes[1].set_ylabel('Number of properties')\n",
- "axes[1].set_title('Distribution of c stability (CV)')\n",
- "axes[1].axvline(x=c_stats['c_cv'].median(), color='red', linestyle='--', label=f'Median ({c_stats['c_cv'].median()}) threshold')\n",
+ "axes[1].hist(c_stats[\"c_cv\"], bins=100, edgecolor=\"black\")\n",
+ "axes[1].set_xlabel(\"CV of c (std/mean)\")\n",
+ "axes[1].set_ylabel(\"Number of properties\")\n",
+ "axes[1].set_title(\"Distribution of c stability (CV)\")\n",
+ "axes[1].axvline(\n",
+ " x=c_stats[\"c_cv\"].median(),\n",
+ " color=\"red\",\n",
+ " linestyle=\"--\",\n",
+ " label=f\"Median ({c_stats['c_cv'].median()}) threshold\",\n",
+ ")\n",
"axes[1].legend()\n",
"\n",
"plt.tight_layout()\n",
"plt.show()\n",
"\n",
"# output text\n",
- "pct_stable = (c_stats['c_cv'] < 0.1).mean() * 100\n",
+ "pct_stable = (c_stats[\"c_cv\"] < 0.1).mean() * 100\n",
"print(f\"{pct_stable:.1f}% of properties have CV < 0.1\")\n",
"\n",
- "pct_stable = (c_stats['c_cv'] < 0.2).mean() * 100\n",
+ "pct_stable = (c_stats[\"c_cv\"] < 0.2).mean() * 100\n",
"print(f\"{pct_stable:.1f}% of properties have CV < 0.2\")\n",
"\n",
- "pct_stable = (c_stats['c_cv'] < 0.3).mean() * 100\n",
+ "pct_stable = (c_stats[\"c_cv\"] < 0.3).mean() * 100\n",
"print(f\"{pct_stable:.1f}% of properties have CV < 0.3\")"
]
},
@@ -3299,7 +3336,7 @@
}
],
"source": [
- "unstable_c = c_stats.sort_values('c_cv', ascending=False)['c_cv'][:20]\n",
+ "unstable_c = c_stats.sort_values(\"c_cv\", ascending=False)[\"c_cv\"][:20]\n",
"display(unstable_c)\n",
"\n",
"unstable_c_specific = random.randint(0, 20)\n",
@@ -3308,41 +3345,43 @@
"print(unstable_c.index[unstable_c_specific][1])\n",
"\n",
"# pc avg trend\n",
- "temp_pc_avg = pc_avg_complex[pc_avg_complex['Postcode'] == unstable_c.index[unstable_c_specific][0]].sort_values(by='year')\n",
+ "temp_pc_avg = pc_avg_complex[\n",
+ " pc_avg_complex[\"Postcode\"] == unstable_c.index[unstable_c_specific][0]\n",
+ "].sort_values(by=\"year\")\n",
"display(temp_pc_avg)\n",
"\n",
"# c for specific postcode\n",
"temp_postcode = data_small[\n",
- " (data_small['Postcode'] == unstable_c.index[unstable_c_specific][0]) \n",
- " # & (data_small['Address per EPC'] == unstable_c.index[unstable_c_specific][1]) \n",
- "].sort_values(by='year')\n",
+ " (data_small[\"Postcode\"] == unstable_c.index[unstable_c_specific][0])\n",
+ " # & (data_small['Address per EPC'] == unstable_c.index[unstable_c_specific][1])\n",
+ "].sort_values(by=\"year\")\n",
"display(temp_address)\n",
"\n",
"# c for specific address\n",
"temp_address = data_small[\n",
- " (data_small['Postcode'] == unstable_c.index[unstable_c_specific][0]) \n",
- " & (data_small['Address per EPC'] == unstable_c.index[unstable_c_specific][1]) \n",
- "].sort_values(by='year')\n",
+ " (data_small[\"Postcode\"] == unstable_c.index[unstable_c_specific][0])\n",
+ " & (data_small[\"Address per EPC\"] == unstable_c.index[unstable_c_specific][1])\n",
+ "].sort_values(by=\"year\")\n",
"display(temp_address)\n",
"\n",
"# plot\n",
"\n",
"fig, ax1 = plt.subplots()\n",
"\n",
- "temp_pc_avg.plot.line(x='year', y='Price per sqm PC AVG', ax=ax1, color='black')\n",
- "temp_address.plot.line(x='year', y='Price per sqm', ax=ax1, color='green') \n",
+ "temp_pc_avg.plot.line(x=\"year\", y=\"Price per sqm PC AVG\", ax=ax1, color=\"black\")\n",
+ "temp_address.plot.line(x=\"year\", y=\"Price per sqm\", ax=ax1, color=\"green\")\n",
"\n",
"ax2 = ax1.twinx()\n",
"\n",
- "for property in temp_postcode['Address per EPC'].unique():\n",
- " property_data = temp_postcode[temp_postcode['Address per EPC'] == property]\n",
- " property_data.plot.line(x='year', y='c', ax=ax2, color='orange', style=':')\n",
- "temp_address.plot.line(x='year', y='c', ax=ax2, color='red', style=':')\n",
+ "for property in temp_postcode[\"Address per EPC\"].unique():\n",
+ " property_data = temp_postcode[temp_postcode[\"Address per EPC\"] == property]\n",
+ " property_data.plot.line(x=\"year\", y=\"c\", ax=ax2, color=\"orange\", style=\":\")\n",
+ "temp_address.plot.line(x=\"year\", y=\"c\", ax=ax2, color=\"red\", style=\":\")\n",
"\n",
- "ax1.set_ylabel('Price per sqm')\n",
- "ax2.set_ylabel('c')\n",
+ "ax1.set_ylabel(\"Price per sqm\")\n",
+ "ax2.set_ylabel(\"c\")\n",
"\n",
- "plt.show()\n"
+ "plt.show()"
]
},
{
@@ -3370,11 +3409,11 @@
],
"source": [
"# select random address\n",
- "one_property = data_small.sample(1)[['Postcode', 'Address per EPC']].iloc[0]\n",
- "postcode = one_property['Postcode']\n",
- "address = one_property['Address per EPC']\n",
- "print(f'Postcode: {postcode}')\n",
- "print(f'Address: {address}')"
+ "one_property = data_small.sample(1)[[\"Postcode\", \"Address per EPC\"]].iloc[0]\n",
+ "postcode = one_property[\"Postcode\"]\n",
+ "address = one_property[\"Address per EPC\"]\n",
+ "print(f\"Postcode: {postcode}\")\n",
+ "print(f\"Address: {address}\")"
]
},
{
@@ -3481,22 +3520,21 @@
],
"source": [
"property_data = data_small[\n",
- " (data_small['Postcode'] == postcode) \n",
- " & (data_small['Address per EPC'] == address) \n",
+ " (data_small[\"Postcode\"] == postcode) & (data_small[\"Address per EPC\"] == address)\n",
"]\n",
- "latest_year = property_data['year'].max()\n",
- "print(f'Latest year of data: {latest_year}')\n",
+ "latest_year = property_data[\"year\"].max()\n",
+ "print(f\"Latest year of data: {latest_year}\")\n",
"\n",
"# Get only the latest year's data for this property (this is what we want to predict)\n",
- "data_small_test = property_data[property_data['year'] == latest_year]\n",
+ "data_small_test = property_data[property_data[\"year\"] == latest_year]\n",
"\n",
"# Remove only the latest year's data from training (keep historical data for this property)\n",
"data_small_train = data_small.drop(data_small_test.index)\n",
"\n",
"print()\n",
- "print(f'data_small.shape = {data_small.shape}')\n",
- "print(f'data_small_train.shape = {data_small_train.shape}')\n",
- "print(f'data_small_test.shape = {data_small_test.shape}')\n",
+ "print(f\"data_small.shape = {data_small.shape}\")\n",
+ "print(f\"data_small_train.shape = {data_small_train.shape}\")\n",
+ "print(f\"data_small_test.shape = {data_small_test.shape}\")\n",
"display(data_small_test)\n",
"data_small.shape[0] == data_small_test.shape[0] + data_small_train.shape[0]"
]
@@ -3607,9 +3645,9 @@
"# get latest c in data_small_train\n",
"\n",
"latest_train_address = data_small_train[\n",
- " (data_small_train['Postcode'] == postcode) \n",
- " & (data_small_train['Address per EPC'] == address) \n",
- "].sort_values(by='year')\n",
+ " (data_small_train[\"Postcode\"] == postcode)\n",
+ " & (data_small_train[\"Address per EPC\"] == address)\n",
+ "].sort_values(by=\"year\")\n",
"\n",
"latest_train_address"
]
@@ -3630,10 +3668,10 @@
}
],
"source": [
- "latest_train_c = latest_train_address['c'].iloc[-1]\n",
- "latest_train_pc_avg = latest_train_address['Price per sqm PC AVG'].iloc[-1]\n",
- "print(f'Latest c in training data: {latest_train_c:.3f}')\n",
- "print(f'Latest price per sqm in training data: {latest_train_pc_avg:.2f}') "
+ "latest_train_c = latest_train_address[\"c\"].iloc[-1]\n",
+ "latest_train_pc_avg = latest_train_address[\"Price per sqm PC AVG\"].iloc[-1]\n",
+ "print(f\"Latest c in training data: {latest_train_c:.3f}\")\n",
+ "print(f\"Latest price per sqm in training data: {latest_train_pc_avg:.2f}\")"
]
},
{
@@ -3654,7 +3692,7 @@
}
],
"source": [
- "latest_train_c * latest_train_pc_avg * data_small_test['Total floor area (sqm)'].iloc[0]"
+ "latest_train_c * latest_train_pc_avg * data_small_test[\"Total floor area (sqm)\"].iloc[0]"
]
},
{
diff --git a/r5-java/run.sh b/r5-java/run.sh
index ca89cee..35b3942 100755
--- a/r5-java/run.sh
+++ b/r5-java/run.sh
@@ -22,7 +22,7 @@ set -euo pipefail
# --demo only compute Bank + TCR, transit only (quick test)
# --- Defaults ---
-THREADS=8
+THREADS=16
HEAP=16g
NETWORK_DIR=property-data/r5-network
OUTPUT_BASE=property-data/travel-times
diff --git a/screenshot/src/screenshot.ts b/screenshot/src/screenshot.ts
index 1cf4869..097f078 100644
--- a/screenshot/src/screenshot.ts
+++ b/screenshot/src/screenshot.ts
@@ -5,7 +5,7 @@ import { NetworkCache } from './network-cache.js';
const VIEWPORT = { width: 1200, height: 630 };
const NAVIGATION_TIMEOUT = 15_000;
const READY_TIMEOUT = 15_000;
-const RENDER_BUFFER_MS = 200;
+const RENDER_BUFFER_MS = 500;
const POOL_SIZE = 3;
let browser: Browser | null = null;
@@ -226,11 +226,22 @@ export async function initialize(appUrl: string): Promise {
await warmPool();
}
-export async function takeScreenshot(url: string): Promise {
+export async function takeScreenshot(url: string, authHeader?: string): Promise {
const page = await acquirePage();
const t0 = performance.now();
try {
+ // Inject Authorization header on API requests so the headless browser
+ // is authenticated (required for licensed users outside the free zone).
+ // Page-level routes take precedence over the context-level cache route,
+ // so only /api/ requests are affected — static assets still use the cache.
+ if (authHeader) {
+ await page.route('**/api/**', async (route) => {
+ const headers = { ...route.request().headers(), authorization: authHeader };
+ await route.continue({ headers });
+ });
+ }
+
const response = await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: NAVIGATION_TIMEOUT,
@@ -251,9 +262,10 @@ export async function takeScreenshot(url: string): Promise {
const t2 = performance.now();
console.log(` Ready: ${(t2 - t1).toFixed(0)}ms`);
- // Brief buffer for SwiftShader to finish rendering the WebGL frame.
- // Reduced from 500ms → 200ms since tiles now load from the in-memory
- // cache and don't need network round-trips.
+ // Buffer for SwiftShader to finish rendering the WebGL frame after
+ // __screenshot_ready fires. The frontend uses double-rAF before signaling,
+ // so one paint cycle has already completed — this is extra safety for
+ // compositor staging and any residual tile/layer rendering.
await page.waitForTimeout(RENDER_BUFFER_MS);
// JPEG at quality 85: ~3-5x faster encoding than PNG with negligible
@@ -265,6 +277,11 @@ export async function takeScreenshot(url: string): Promise {
return Buffer.from(screenshot);
} finally {
+ // Remove page-level auth route before returning page to pool
+ // so the next screenshot doesn't inherit stale credentials
+ if (authHeader) {
+ await page.unrouteAll({ behavior: 'wait' }).catch(() => {});
+ }
await releasePage(page);
}
}
diff --git a/screenshot/src/server.ts b/screenshot/src/server.ts
index ff91c42..3ea1fad 100644
--- a/screenshot/src/server.ts
+++ b/screenshot/src/server.ts
@@ -57,7 +57,12 @@ app.get('/screenshot', async (req, res) => {
const pagePath = typeof req.query.path === 'string' && req.query.path ? req.query.path : '/';
if (pagePath !== '/') qs.set('path', pagePath);
+ // Include auth status in cache key so authenticated screenshots
+ // (with hexagons outside free zone) are cached separately
+ const authHeader = req.headers.authorization;
+ if (authHeader) qs.set('_auth', '1');
const cacheKey = cache.buildKey(qs);
+ qs.delete('_auth');
qs.delete('path');
// Check cache first
@@ -74,8 +79,8 @@ app.get('/screenshot', async (req, res) => {
qs.set('screenshot', '1');
const url = `${APP_URL}${pagePath}?${qs}`;
- console.log(`Taking screenshot: ${url}`);
- const jpeg = await takeScreenshot(url);
+ console.log(`Taking screenshot: ${url}${authHeader ? ' (authenticated)' : ''}`);
+ const jpeg = await takeScreenshot(url, authHeader);
// Cache it
cache.set(cacheKey, jpeg);
diff --git a/scripts/remove_bg.py b/scripts/remove_bg.py
index 4bf87d8..a05be12 100644
--- a/scripts/remove_bg.py
+++ b/scripts/remove_bg.py
@@ -4,6 +4,7 @@ import sys
from collections import deque
from PIL import Image
+
def remove_white_bg(path: str, tolerance: int = 20, out: str | None = None):
img = Image.open(path).convert("RGBA")
pixels = img.load()
@@ -43,6 +44,7 @@ def remove_white_bg(path: str, tolerance: int = 20, out: str | None = None):
img.save(dest)
print(f"Saved to {dest} ({img.size[0]}x{img.size[1]})")
+
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python remove_bg.py [tolerance] [output]")
diff --git a/scripts/zoopla_experiment.py b/scripts/zoopla_experiment.py
new file mode 100755
index 0000000..aa4f4fc
--- /dev/null
+++ b/scripts/zoopla_experiment.py
@@ -0,0 +1,281 @@
+#!/usr/bin/env -S uv run --project ../finder
+"""Zoopla scraping experiment — working prototype using Camoufox.
+
+Key findings:
+ - Zoopla uses Cloudflare Turnstile (managed interactive challenge)
+ - Playwright headless Chromium + stealth patches CANNOT beat it
+ - Camoufox (anti-fingerprinting Firefox fork) PASSES Cloudflare
+ - Zoopla uses Next.js App Router with React Server Components (RSC)
+ - Listing data is NOT in __NEXT_DATA__ — it's server-rendered in RSC stream
+ - URL-based location slugs (e.g. /properties/london/) return 0 results
+ - Must use the search autocomplete (GraphQL: getGeoSuggestion) to resolve
+ a location, then submit the form to get results
+ - GraphQL endpoint: api-graphql-lambda.prod.zoopla.co.uk/graphql
+ - Listings loaded via getTopLeadListingIds + getRareFindLeadListingIds ops
+
+Usage:
+ uv run --project finder scripts/zoopla_experiment.py [LOCATION]
+ uv run --project finder scripts/zoopla_experiment.py "Tower Hamlets"
+"""
+
+import json
+import logging
+import re
+import sys
+import time
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)-8s %(message)s",
+ datefmt="%H:%M:%S",
+)
+log = logging.getLogger("zoopla-exp")
+
+
+def scrape_zoopla(location: str = "London", channel: str = "BUY"):
+ from camoufox.sync_api import Camoufox
+
+ tab_label = "Buy" if channel == "BUY" else "Rent"
+ log.info("Scraping Zoopla: location=%s channel=%s", location, channel)
+
+ with Camoufox(headless=True) as browser:
+ page = browser.new_page()
+
+ # Intercept GraphQL responses
+ graphql_responses = []
+
+ def on_resp(response):
+ url = response.url
+ ct = response.headers.get("content-type", "")
+ if "json" in ct and "graphql" in url:
+ try:
+ body = response.json()
+ req = response.request.post_data or ""
+ graphql_responses.append({"body": body, "req": req})
+ except Exception:
+ pass
+
+ page.on("response", on_resp)
+
+ # Step 1: Load homepage and pass Cloudflare
+ log.info("Loading Zoopla homepage...")
+ page.goto("https://www.zoopla.co.uk/", wait_until="domcontentloaded", timeout=60000)
+
+ for i in range(20):
+ if "Just a moment" not in page.title():
+ break
+ time.sleep(3)
+ else:
+ log.error("Cloudflare did not resolve after 60s")
+ return []
+
+ log.info("Homepage loaded: %s", page.title())
+ time.sleep(3)
+
+ # Step 2: Dismiss cookie consent (shadow DOM)
+ page.evaluate("""() => {
+ const aside = document.querySelector('#usercentrics-cmp-ui');
+ if (aside && aside.shadowRoot) {
+ const btns = aside.shadowRoot.querySelectorAll('button');
+ for (const btn of btns) {
+ if (btn.innerText.includes('Accept')) { btn.click(); return; }
+ }
+ }
+ aside?.remove();
+ }""")
+ time.sleep(2)
+
+ # Step 3: Select Buy/Rent tab if needed
+ if channel == "RENT":
+ rent_tab = page.query_selector('button:has-text("Rent")') or page.query_selector(f'[role="tab"]:has-text("{tab_label}")')
+ if rent_tab:
+ rent_tab.click()
+ time.sleep(1)
+
+ # Step 4: Type location into search and select autocomplete suggestion
+ log.info("Searching for '%s'...", location)
+ search_input = (
+ page.query_selector('input[name="autosuggest-input"]')
+ or page.query_selector('input[type="text"]')
+ )
+ if not search_input:
+ log.error("Could not find search input")
+ return []
+
+ search_input.click()
+ time.sleep(0.5)
+ search_input.fill("") # Clear any existing text
+ search_input.type(location, delay=80)
+ time.sleep(3)
+
+ # Select first autocomplete suggestion
+ first_option = page.query_selector('[role="option"]')
+ if first_option:
+ suggestion_text = first_option.inner_text()
+ log.info("Selecting suggestion: %s", suggestion_text)
+ first_option.click()
+ time.sleep(1)
+ else:
+ log.warning("No autocomplete suggestions appeared")
+
+ # Step 5: Submit search
+ search_btn = page.query_selector('button:has-text("Search")')
+ if search_btn:
+ search_btn.click()
+ else:
+ search_input.press("Enter")
+
+ log.info("Waiting for results...")
+ time.sleep(10)
+
+ final_url = page.url
+ final_title = page.title()
+ log.info("URL: %s", final_url)
+ log.info("Title: %s", final_title)
+
+ # Step 6: Extract listings from rendered DOM
+ listings = page.evaluate(r"""() => {
+ const links = Array.from(document.querySelectorAll(
+ 'a[href*="/for-sale/details/"], a[href*="/new-homes/details/"], a[href*="/to-rent/details/"]'
+ ));
+
+ const seen = new Set();
+ const results = [];
+
+ for (const link of links) {
+ const href = link.href;
+ const match = href.match(/\/details\/(\d+)\//);
+ if (!match) continue;
+
+ const id = match[1];
+ if (seen.has(id)) continue;
+ seen.add(id);
+
+ // Walk up to find the listing card container
+ let card = link;
+ for (let j = 0; j < 10; j++) {
+ card = card.parentElement;
+ if (!card) break;
+ const text = card.innerText || '';
+ // A listing card should have a price and at least beds or area
+ if (text.includes('£') && (text.includes('bed') || text.includes('sq ft'))) {
+ break;
+ }
+ }
+ if (!card) continue;
+
+ const text = card.innerText || '';
+ const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
+
+ const priceMatch = text.match(/£([\d,]+)/);
+ const bedsMatch = text.match(/(\d+)\s*beds?/i);
+ const bathsMatch = text.match(/(\d+)\s*baths?/i);
+ const recMatch = text.match(/(\d+)\s*reception/i);
+ const areaMatch = text.match(/([\d,]+)\s*sq\s*ft/i);
+
+ // Try to find address — usually a line with a postcode or comma-separated location
+ let address = '';
+ for (const line of lines) {
+ if (/[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}/i.test(line) ||
+ (line.includes(',') && !line.includes('£') && !line.match(/^\d+ beds?/i))) {
+ address = line;
+ break;
+ }
+ }
+
+ // Tenure
+ let tenure = '';
+ if (/freehold/i.test(text)) tenure = 'Freehold';
+ else if (/leasehold/i.test(text)) tenure = 'Leasehold';
+
+ results.push({
+ id: id,
+ url: href.replace(window.location.origin, ''),
+ price: priceMatch ? parseInt(priceMatch[1].replace(/,/g, '')) : null,
+ beds: bedsMatch ? parseInt(bedsMatch[1]) : null,
+ baths: bathsMatch ? parseInt(bathsMatch[1]) : null,
+ receptions: recMatch ? parseInt(recMatch[1]) : null,
+ floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
+ address: address,
+ tenure: tenure,
+ text_preview: lines.slice(0, 10).join(' | '),
+ });
+ }
+
+ return results;
+ }""")
+
+ log.info("Extracted %d unique listings from page 1", len(listings))
+
+ # Step 7: Check for results count and pagination
+ body_text = page.inner_text("body")
+ count_match = re.search(r"([\d,]+)\s+results?", body_text)
+ total_results = int(count_match.group(1).replace(",", "")) if count_match else len(listings)
+ log.info("Total results: %d", total_results)
+
+ # Step 8: Log GraphQL operations we saw
+ log.info("GraphQL operations intercepted:")
+ for gql in graphql_responses:
+ try:
+ req = json.loads(gql["req"])
+ op = req.get("operationName", "?")
+ log.info(" - %s", op)
+ except Exception:
+ pass
+
+ # Step 9: Extract cookies for potential curl_cffi reuse
+ cookies = page.context.cookies()
+ session_cookies = {
+ c["name"]: c["value"]
+ for c in cookies
+ if "zoopla" in c.get("domain", "") or "cf" in c.get("name", "").lower()
+ }
+ ua = page.evaluate("navigator.userAgent")
+
+ return {
+ "url": final_url,
+ "title": final_title,
+ "total_results": total_results,
+ "listings": listings,
+ "cookies": session_cookies,
+ "user_agent": ua,
+ }
+
+
+def main():
+ location = sys.argv[1] if len(sys.argv) > 1 else "London"
+
+ result = scrape_zoopla(location, channel="BUY")
+ if not result:
+ log.error("Scraping failed")
+ sys.exit(1)
+
+ listings = result["listings"]
+ print(f"\n{'='*60}")
+ print(f" Zoopla: {result['title']}")
+ print(f" URL: {result['url']}")
+ print(f" Total: {result['total_results']} results, {len(listings)} extracted")
+ print(f"{'='*60}\n")
+
+ for i, listing in enumerate(listings):
+ print(f"--- Listing {i+1}: {listing['url']} ---")
+ display = {k: v for k, v in listing.items() if k != "text_preview" and v}
+ print(json.dumps(display, indent=2, ensure_ascii=False))
+ print()
+
+ # Summary stats
+ prices = [item["price"] for item in listings if item["price"]]
+ beds = [item["beds"] for item in listings if item["beds"]]
+ if prices:
+ print(f"Price range: £{min(prices):,} - £{max(prices):,}")
+ print(f"Median: £{sorted(prices)[len(prices)//2]:,}")
+ if beds:
+ print(f"Bedrooms: {min(beds)}-{max(beds)}")
+
+ # Cookie info for reuse
+ print(f"\nSession cookies ({len(result['cookies'])} cookies)")
+ print(f"User-Agent: {result['user_agent']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml
index 72ad1e9..01a06eb 100644
--- a/server-rs/Cargo.toml
+++ b/server-rs/Cargo.toml
@@ -9,7 +9,7 @@ clap = { version = "4", features = ["derive", "env"] }
axum = "0.8"
tower-http = { version = "0.6", features = ["cors", "fs", "compression-gzip", "compression-zstd", "trace"] }
tokio = { version = "1", features = ["full"] }
-polars = { version = "0.46", features = ["parquet", "lazy", "dtype-struct", "dtype-u8", "dtype-u16", "dtype-i8", "dtype-i16"] }
+polars = { version = "0.46", features = ["parquet", "lazy", "dtype-struct", "dtype-u8", "dtype-u16", "dtype-i8", "dtype-i16", "round_series"] }
h3o = "0.7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
diff --git a/server-rs/logs/server.log.2026-03-15 b/server-rs/logs/server.log.2026-03-15
new file mode 100644
index 0000000..2c7e6dd
--- /dev/null
+++ b/server-rs/logs/server.log.2026-03-15
@@ -0,0 +1,1192 @@
+2026-03-15T19:07:11.371851Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T19:07:11.372040Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T19:07:11.372050Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T19:07:11.473120Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T19:07:11.473130Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T19:07:22.441464Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T19:07:22.441476Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T19:07:22.848304Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T19:07:22.848315Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T19:07:22.913269Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T19:07:22.913279Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T19:07:33.981737Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T19:07:33.981832Z INFO property_map_server::data::property: Feature columns from config numeric=54 enums=13 total=67
+2026-03-15T19:07:35.443457Z INFO property_map_server::data::property: Combined data selected rows=15702691
+2026-03-15T19:07:35.594896Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-15T19:07:36.004267Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-15T19:07:37.616212Z INFO property_map_server::data::property: Extracting string columns
+2026-03-15T19:07:39.772016Z INFO property_map_server::data::property: Building enum features
+2026-03-15T19:07:41.167109Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-15T19:07:49.922117Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-15T19:07:49.922128Z INFO property_map_server::data::property: Extracting listing features
+2026-03-15T19:07:50.511333Z INFO property_map_server::data::property: Listing features extracted properties_with_features=412749
+2026-03-15T19:07:50.511342Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-15T19:07:51.475798Z INFO property_map_server::data::property: Building interned strings
+2026-03-15T19:07:57.644399Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-15T19:08:00.205219Z INFO property_map_server::data::property: Data loading complete
+2026-03-15T19:08:01.951719Z INFO property_map_server: Property data loaded rows=15702691 features=67 enums=13
+2026-03-15T19:08:01.951728Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-15T19:08:02.049262Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-15T19:08:02.049271Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-15T19:08:02.477049Z INFO property_map_server::data::property: H3 precomputation complete (15702691 cells)
+2026-03-15T19:08:02.477606Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-15T19:08:02.477614Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-15T19:08:02.501985Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-15T19:08:02.612770Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-15T19:08:02.613426Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-15T19:08:02.652324Z INFO property_map_server: POI data loaded pois=678242
+2026-03-15T19:08:02.652334Z INFO property_map_server: Building POI spatial grid index
+2026-03-15T19:08:02.658355Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-15T19:08:02.658366Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-15T19:08:02.660009Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-15T19:08:02.660839Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-15T19:08:02.660901Z INFO property_map_server: Place data loaded places=3474
+2026-03-15T19:08:02.660910Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-15T19:08:02.660914Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-15T19:08:02.664216Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-15T19:08:04.225988Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-15T19:08:04.225999Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-15T19:08:04.226018Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-15T19:08:04.265284Z INFO property_map_server: PMTiles loaded successfully
+2026-03-15T19:08:04.310151Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-15T19:08:04.343659Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-15T19:08:04.343821Z INFO property_map_server: Precomputed features response groups=9
+2026-03-15T19:08:04.343836Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-15T19:08:04.432857Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-15T19:08:04.438796Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-15T19:08:04.443153Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-15T19:08:04.727071Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfectpostcodes.schmelczer.dev/pb
+2026-03-15T19:08:04.742347Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-15T19:08:04.742398Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-15T19:08:04.742414Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-15T19:08:04.783484Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-15T19:08:04.784506Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-15T19:08:04.785961Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-15T19:08:04.794234Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1849
+2026-03-15T19:08:04.794259Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-15T19:08:04.794312Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-15T19:08:07.250052Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-15T19:08:07.250147Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-15T19:08:43.332953Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=905 cells_after_filter=904 truncated=false bounds=50.5989,-1.9210,52.4537,1.7355 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=70.2 total_ms=82.4
+2026-03-15T19:08:44.121660Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1337 cells_after_filter=1335 truncated=false bounds=50.5989,-1.9210,52.4537,1.7355 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=68.3 total_ms=84.2
+2026-03-15T19:08:45.206450Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1337 cells_after_filter=1335 truncated=false bounds=50.5989,-1.9210,52.4537,1.7355 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=71.1 total_ms=94.8
+2026-03-15T19:08:45.422613Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1356 cells_after_filter=1351 truncated=false bounds=50.5989,-1.9210,52.4537,1.7355 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=1077.7 total_ms=1109.0
+2026-03-15T19:08:47.208854Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1337 cells_after_filter=1335 truncated=false bounds=50.5989,-1.9210,52.4537,1.7355 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=68.0 total_ms=79.9
+2026-03-15T19:08:47.294458Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1356 cells_after_filter=1351 truncated=false bounds=50.5989,-1.9210,52.4537,1.7355 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=1074.4 total_ms=1104.8
+2026-03-15T19:08:48.178659Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=905 cells_after_filter=904 truncated=false bounds=50.5989,-1.9210,52.4537,1.7355 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=79.6 total_ms=86.6
+2026-03-15T19:08:48.443812Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1337 cells_after_filter=1335 truncated=false bounds=50.5989,-1.9210,52.4537,1.7355 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=71.5 total_ms=83.5
+2026-03-15T19:08:51.505423Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=86195dac7ffffff resolution=6 total_count=602 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" ms=1.0
+2026-03-15T19:08:52.293946Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1004 cells_after_filter=1004 truncated=false bounds=50.5989,-1.3279,52.4537,1.1425 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=55.5 total_ms=64.3
+2026-03-15T19:09:12.606611Z INFO property_map_server::routes::properties: GET /api/hexagon-properties h3=86195dac7ffffff resolution=6 total=602 returned=100 offset=0 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" ms=1.4
+2026-03-15T19:09:17.589934Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=86195dac7ffffff resolution=6 total_count=25877 filters=1 filters_raw="Listing status:Historical sale" ms=12.0
+2026-03-15T19:09:19.152498Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1021 cells_after_filter=1017 truncated=false bounds=50.5989,-1.3279,52.4537,1.1425 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=944.3 total_ms=960.2
+2026-03-15T19:09:19.587280Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=86195dac7ffffff resolution=6 total_count=602 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" ms=1.0
+2026-03-15T19:09:20.120819Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1004 cells_after_filter=1004 truncated=false bounds=50.5989,-1.3279,52.4537,1.1425 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=59.6 total_ms=68.9
+2026-03-15T19:09:21.757704Z INFO property_map_server::routes::properties: GET /api/hexagon-properties h3=86195dac7ffffff resolution=6 total=602 returned=100 offset=0 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" ms=1.3
+2026-03-15T19:09:26.458314Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=86195dac7ffffff resolution=6 total_count=25877 filters=1 filters_raw="Listing status:Historical sale" ms=12.2
+2026-03-15T19:09:26.734420Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=86195dac7ffffff resolution=6 total_count=602 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" ms=1.1
+2026-03-15T19:09:27.270779Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1004 cells_after_filter=1004 truncated=false bounds=50.5989,-1.3279,52.4537,1.1425 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=59.2 total_ms=68.7
+2026-03-15T19:09:27.465769Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=86195dac7ffffff resolution=6 total_count=29 filters=1 filters_raw="Listing status:For rent" ms=1.5
+2026-03-15T19:09:27.551401Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1021 cells_after_filter=1017 truncated=false bounds=50.5989,-1.3279,52.4537,1.1425 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=938.9 total_ms=955.1
+2026-03-15T19:09:27.636702Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=688 cells_after_filter=688 truncated=false bounds=50.5989,-1.3279,52.4537,1.1425 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=64.2 total_ms=69.4
+2026-03-15T19:09:28.107949Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=86195dac7ffffff resolution=6 total_count=602 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" ms=1.0
+2026-03-15T19:09:28.647682Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1004 cells_after_filter=1004 truncated=false bounds=50.5989,-1.3279,52.4537,1.1425 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=57.9 total_ms=66.8
+2026-03-15T19:10:43.696300Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=86195dac7ffffff resolution=6 total_count=29 filters=1 filters_raw="Listing status:For rent" ms=1.5
+2026-03-15T19:10:43.917385Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=688 cells_after_filter=688 truncated=false bounds=50.5989,-1.3279,52.4537,1.1425 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=66.4 total_ms=72.3
+2026-03-15T19:10:44.773099Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=86195dac7ffffff resolution=6 total_count=602 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" ms=0.9
+2026-03-15T19:10:45.309165Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1004 cells_after_filter=1004 truncated=false bounds=50.5989,-1.3279,52.4537,1.1425 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=55.8 total_ms=65.3
+2026-03-15T19:11:44.827084Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T19:11:45.019108Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T19:11:45.539093Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=67.3 total_ms=79.7
+2026-03-15T19:12:22.900048Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated current price:23297.51:inf" travel_entries=0 agg_ms=1266.0 total_ms=1293.0
+2026-03-15T19:12:43.487506Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=97.7 total_ms=119.2
+2026-03-15T19:12:56.981906Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=92.3 total_ms=105.6
+2026-03-15T19:15:16.007945Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T19:15:16.008103Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T19:15:16.008110Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T19:15:16.129677Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T19:15:16.129690Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T19:15:25.994671Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T19:15:25.994690Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T19:15:26.528002Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T19:15:26.528016Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T19:15:26.610377Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T19:15:26.610387Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T19:16:11.721573Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T19:16:11.721688Z INFO property_map_server::data::property: Feature columns from config numeric=54 enums=13 total=67
+2026-03-15T19:16:13.315773Z INFO property_map_server::data::property: Combined data selected rows=15702691
+2026-03-15T19:16:13.525913Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-15T19:16:14.026360Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-15T19:16:15.880642Z INFO property_map_server::data::property: Extracting string columns
+2026-03-15T19:16:18.439507Z INFO property_map_server::data::property: Building enum features
+2026-03-15T19:16:20.159326Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-15T19:16:22.764657Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-15T19:16:22.764667Z INFO property_map_server::data::property: Extracting listing features
+2026-03-15T19:16:23.416811Z INFO property_map_server::data::property: Listing features extracted properties_with_features=412749
+2026-03-15T19:16:23.416820Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-15T19:16:24.612917Z INFO property_map_server::data::property: Building interned strings
+2026-03-15T19:16:31.709728Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-15T19:16:34.550669Z INFO property_map_server::data::property: Data loading complete
+2026-03-15T19:16:36.027023Z INFO property_map_server: Property data loaded rows=15702691 features=67 enums=13
+2026-03-15T19:16:36.027032Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-15T19:16:36.427705Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-15T19:16:36.427713Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-15T19:16:36.848803Z INFO property_map_server::data::property: H3 precomputation complete (15702691 cells)
+2026-03-15T19:16:36.848848Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-15T19:16:36.848862Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-15T19:16:36.922825Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-15T19:16:37.040700Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-15T19:16:37.041316Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-15T19:16:37.080709Z INFO property_map_server: POI data loaded pois=678242
+2026-03-15T19:16:37.080719Z INFO property_map_server: Building POI spatial grid index
+2026-03-15T19:16:37.087492Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-15T19:16:37.087505Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-15T19:16:37.092797Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-15T19:16:37.093630Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-15T19:16:37.093696Z INFO property_map_server: Place data loaded places=3474
+2026-03-15T19:16:37.093709Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-15T19:16:37.093714Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-15T19:16:37.097696Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-15T19:16:40.166666Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-15T19:16:40.166676Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-15T19:16:40.166689Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-15T19:16:40.223398Z INFO property_map_server: PMTiles loaded successfully
+2026-03-15T19:16:40.267971Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-15T19:16:40.329251Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-15T19:16:40.329417Z INFO property_map_server: Precomputed features response groups=9
+2026-03-15T19:16:40.329432Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-15T19:16:40.466894Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-15T19:16:40.474329Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-15T19:16:40.488014Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-15T19:16:40.724141Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfectpostcodes.schmelczer.dev/pb
+2026-03-15T19:16:40.728811Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-15T19:16:40.728841Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-15T19:16:40.728857Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-15T19:16:40.781239Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-15T19:16:40.786720Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-15T19:16:40.789828Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-15T19:16:40.812650Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1849
+2026-03-15T19:16:40.812688Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-15T19:16:40.812745Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-15T19:16:44.806149Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-15T19:16:44.806208Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-15T19:18:41.554399Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=1102.3 total_ms=1142.0
+2026-03-15T19:18:51.943051Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated current price:23297.51:inf" travel_entries=0 agg_ms=1089.3 total_ms=1111.0
+2026-03-15T19:18:52.235271Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=88.8 total_ms=101.0
+2026-03-15T19:18:53.034978Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=71.2 total_ms=79.2
+2026-03-15T19:18:54.485285Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=86.6 total_ms=98.8
+2026-03-15T19:18:56.331564Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated current price:23297.51:inf" travel_entries=0 agg_ms=1100.7 total_ms=1122.4
+2026-03-15T19:18:57.013412Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=86.3 total_ms=98.3
+2026-03-15T19:18:57.497646Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=80.4 total_ms=87.3
+2026-03-15T19:18:58.307229Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=86.4 total_ms=98.6
+2026-03-15T19:18:59.209272Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=92.3 total_ms=115.9
+2026-03-15T19:18:59.605162Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated current price:23297.51:inf" travel_entries=0 agg_ms=1104.1 total_ms=1125.8
+2026-03-15T19:19:00.641552Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=101.5 total_ms=114.0
+2026-03-15T19:19:01.055691Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated current price:23297.51:inf" travel_entries=0 agg_ms=1102.8 total_ms=1133.7
+2026-03-15T19:19:01.767817Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=70.5 total_ms=77.5
+2026-03-15T19:19:02.094672Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=95.1 total_ms=107.1
+2026-03-15T19:19:04.696909Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated current price:23297.51:inf" travel_entries=0 agg_ms=1094.7 total_ms=1116.4
+2026-03-15T19:20:18.135252Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=87.8 total_ms=99.9
+2026-03-15T19:21:06.589853Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=70.6 total_ms=77.5
+2026-03-15T19:22:21.723624Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T19:22:21.723777Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T19:22:21.723788Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T19:22:21.792919Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T19:22:21.792931Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T19:24:04.122070Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T19:24:04.122238Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T19:24:04.122243Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T19:24:04.183691Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T19:24:04.183700Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T19:24:10.703405Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T19:24:10.703567Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T19:24:10.703573Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T19:24:10.764393Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T19:24:10.764404Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T19:24:13.175087Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T19:24:13.175096Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T19:24:14.743132Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T19:24:14.743145Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T19:24:15.276707Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T19:24:15.276719Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T19:32:31.326505Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T19:32:31.326667Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T19:32:31.326674Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T19:32:31.468299Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T19:32:31.468311Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T19:32:36.199335Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T19:32:36.199348Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T19:32:36.594288Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T19:32:36.594299Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T19:32:36.659669Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T19:32:36.659679Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T19:33:03.760178Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T19:33:03.760265Z INFO property_map_server::data::property: Feature columns from config numeric=54 enums=13 total=67
+2026-03-15T19:33:05.203275Z INFO property_map_server::data::property: Combined data selected rows=15702691
+2026-03-15T19:33:05.385487Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-15T19:33:05.825530Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-15T19:33:07.452637Z INFO property_map_server::data::property: Extracting string columns
+2026-03-15T19:33:09.737556Z INFO property_map_server::data::property: Building enum features
+2026-03-15T19:33:11.190065Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-15T19:33:13.640495Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-15T19:33:13.640506Z INFO property_map_server::data::property: Extracting listing features
+2026-03-15T19:33:14.224090Z INFO property_map_server::data::property: Listing features extracted properties_with_features=412749
+2026-03-15T19:33:14.224101Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-15T19:33:15.218314Z INFO property_map_server::data::property: Building interned strings
+2026-03-15T19:33:21.691381Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-15T19:33:24.347401Z INFO property_map_server::data::property: Data loading complete
+2026-03-15T19:33:26.411071Z INFO property_map_server: Property data loaded rows=15702691 features=67 enums=13
+2026-03-15T19:33:26.411081Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-15T19:33:26.535487Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-15T19:33:26.535498Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-15T19:33:27.113000Z INFO property_map_server::data::property: H3 precomputation complete (15702691 cells)
+2026-03-15T19:33:27.113028Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-15T19:33:27.113036Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-15T19:33:27.165121Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-15T19:33:27.304878Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-15T19:33:27.305504Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-15T19:33:27.346347Z INFO property_map_server: POI data loaded pois=678242
+2026-03-15T19:33:27.346357Z INFO property_map_server: Building POI spatial grid index
+2026-03-15T19:33:27.352391Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-15T19:33:27.352399Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-15T19:33:27.365758Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-15T19:33:27.366584Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-15T19:33:27.366644Z INFO property_map_server: Place data loaded places=3474
+2026-03-15T19:33:27.366655Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-15T19:33:27.366659Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-15T19:33:27.392761Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-15T19:33:36.895174Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-15T19:33:36.895188Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-15T19:33:36.895208Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-15T19:33:36.998297Z INFO property_map_server: PMTiles loaded successfully
+2026-03-15T19:33:37.053356Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-15T19:33:37.109062Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-15T19:33:37.109355Z INFO property_map_server: Precomputed features response groups=9
+2026-03-15T19:33:37.109374Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-15T19:33:37.762412Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-15T19:33:37.767896Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-15T19:33:37.775340Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-15T19:33:38.060153Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
+2026-03-15T19:33:38.063925Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-15T19:33:38.063950Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-15T19:33:38.063967Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-15T19:33:38.278834Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-15T19:33:38.287416Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-15T19:33:38.292977Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-15T19:33:38.359034Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1849
+2026-03-15T19:33:38.359070Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-15T19:33:38.359129Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-15T19:33:48.937764Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-15T19:33:48.937811Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-15T19:33:49.510480Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T19:33:49.711250Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T19:33:49.717966Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T19:33:49.758705Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T19:33:50.515563Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T19:33:50.515611Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T19:33:50.526782Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T19:33:50.526799Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T19:33:50.624015Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T19:33:50.954642Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T19:33:51.089161Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T19:33:51.089169Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T19:33:51.344202Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated current price:23297.51:inf" travel_entries=0 agg_ms=148.7 total_ms=177.0
+2026-03-15T19:34:49.486571Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T19:34:49.486761Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T19:34:50.105387Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated current price:23297.51:inf" travel_entries=0 agg_ms=147.2 total_ms=181.6
+2026-03-15T19:35:47.333901Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T19:35:47.633561Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T19:35:47.964989Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated current price:23297.51:inf" travel_entries=0 agg_ms=127.2 total_ms=151.0
+2026-03-15T19:36:10.914163Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T19:36:10.935314Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T19:36:11.591360Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated current price:23297.51:inf" travel_entries=0 agg_ms=148.2 total_ms=179.1
+2026-03-15T19:37:10.487304Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:For sale;;Asking price:23297.51:inf" travel_entries=0 agg_ms=30.4 total_ms=42.4
+2026-03-15T19:37:11.520281Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=34.6 total_ms=41.1
+2026-03-15T19:37:12.429971Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=92.6 total_ms=114.8
+2026-03-15T19:39:01.324173Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1374 cells_after_filter=1370 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated monthly rent:300:7750" travel_entries=0 agg_ms=78.6 total_ms=100.6
+2026-03-15T19:39:11.452418Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=34.6 total_ms=46.5
+2026-03-15T19:39:12.258031Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=104.1 total_ms=126.5
+2026-03-15T19:39:13.209101Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=30.2 total_ms=41.9
+2026-03-15T19:39:13.542817Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=86.2 total_ms=108.5
+2026-03-15T19:39:17.811115Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1374 cells_after_filter=1370 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated monthly rent:300:7750" travel_entries=0 agg_ms=78.0 total_ms=100.1
+2026-03-15T19:39:18.543391Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=31.1 total_ms=42.7
+2026-03-15T19:39:21.422553Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=99.7 total_ms=122.0
+2026-03-15T19:39:24.089893Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1374 cells_after_filter=1370 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated monthly rent:300:7750" travel_entries=0 agg_ms=65.8 total_ms=87.6
+2026-03-15T19:39:34.096698Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=878 cells_after_filter=877 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:For rent;;Asking rent (monthly):300:7750" travel_entries=0 agg_ms=34.2 total_ms=40.6
+2026-03-15T19:39:35.136941Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1374 cells_after_filter=1370 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated monthly rent:300:7750" travel_entries=0 agg_ms=77.3 total_ms=99.4
+2026-03-15T19:39:35.997965Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=878 cells_after_filter=877 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:For rent;;Asking rent (monthly):300:7750" travel_entries=0 agg_ms=27.5 total_ms=34.4
+2026-03-15T19:39:36.896448Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=30.0 total_ms=43.4
+2026-03-15T19:39:37.822906Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=97.3 total_ms=119.7
+2026-03-15T19:39:38.005882Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=31.8 total_ms=43.6
+2026-03-15T19:39:38.307634Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=32.0 total_ms=38.7
+2026-03-15T19:39:39.034035Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=32.7 total_ms=44.5
+2026-03-15T19:39:39.374475Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=100.4 total_ms=122.4
+2026-03-15T19:39:39.415811Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=39.0 total_ms=50.9
+2026-03-15T19:39:40.267881Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=28.4 total_ms=35.2
+2026-03-15T19:39:40.542606Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=35.2 total_ms=47.1
+2026-03-15T19:39:40.845586Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=88.6 total_ms=110.3
+2026-03-15T19:39:42.206069Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=32.7 total_ms=44.7
+2026-03-15T19:39:43.318640Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=27.1 total_ms=33.6
+2026-03-15T19:39:44.688592Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=96.4 total_ms=117.7
+2026-03-15T19:39:45.172008Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=29.2 total_ms=36.4
+2026-03-15T19:39:45.850790Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=40.1 total_ms=51.9
+2026-03-15T19:39:46.189922Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=29.0 total_ms=35.7
+2026-03-15T19:39:46.998212Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=86.0 total_ms=108.9
+2026-03-15T19:39:47.177336Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=29.1 total_ms=35.8
+2026-03-15T19:39:47.696691Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=96.6 total_ms=118.0
+2026-03-15T19:39:48.556326Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=911 cells_after_filter=910 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=28.2 total_ms=35.0
+2026-03-15T19:39:48.906118Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=107.8 total_ms=129.7
+2026-03-15T19:39:51.395259Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1374 cells_after_filter=1370 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:Historical sale;;Estimated monthly rent:300:7750" travel_entries=0 agg_ms=66.3 total_ms=88.4
+2026-03-15T19:39:58.878189Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=878 cells_after_filter=877 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=2 filters_raw="Listing status:For rent;;Asking rent (monthly):300:7750" travel_entries=0 agg_ms=31.0 total_ms=37.7
+2026-03-15T19:40:04.517960Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1349 cells_after_filter=1348 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=31.5 total_ms=44.3
+2026-03-15T19:40:17.346685Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=96.9 total_ms=119.8
+2026-03-15T19:47:52.591641Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T19:47:52.591925Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T19:47:53.240629Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=7065688 parallel=true cells_before_filter=1376 cells_after_filter=1372 truncated=false bounds=50.5887,-1.9407,52.4636,1.7553 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=95.9 total_ms=117.5
+2026-03-15T19:48:12.279153Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=4253126 parallel=true cells_before_filter=3093 cells_after_filter=3064 truncated=false bounds=50.9497,-0.9772,51.9159,0.9233 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=54.4 total_ms=137.0
+2026-03-15T19:48:13.512341Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=2883111 parallel=true cells_before_filter=1105 cells_after_filter=1088 truncated=false bounds=51.1146,-0.5552,51.6729,0.5420 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=36.5 total_ms=54.4
+2026-03-15T19:48:14.110557Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=8 rows=1687410 parallel=true cells_before_filter=2479 cells_after_filter=2434 truncated=false bounds=51.2171,-0.3474,51.5584,0.3232 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=22.6 total_ms=61.3
+2026-03-15T19:48:16.632555Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=1007854 parallel=true cells_before_filter=5324 cells_after_filter=5035 truncated=false bounds=51.3626,-0.1740,51.5564,0.2074 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=16.9 total_ms=117.8
+2026-03-15T19:51:26.541773Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=190938 parallel=true cells_before_filter=1076 cells_after_filter=1033 truncated=false bounds=51.4310,-0.0323,51.5159,0.1349 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=3.8 total_ms=20.0
+2026-03-15T19:51:30.533448Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=384437 parallel=true cells_before_filter=2704 cells_after_filter=2497 truncated=false bounds=51.4379,0.0032,51.5871,0.2970 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=7.4 total_ms=46.9
+2026-03-15T19:51:31.069688Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=632131 parallel=true cells_before_filter=3388 cells_after_filter=3150 truncated=false bounds=51.4091,-0.1211,51.5583,0.1727 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=10.9 total_ms=60.6
+2026-03-15T19:51:35.046811Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=65919 parallel=true cells_before_filter=341 cells_after_filter=288 truncated=false bounds=51.4524,-0.0211,51.4936,0.0599 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=1.6 total_ms=6.2
+2026-03-15T19:51:36.299041Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=63016 parallel=true cells_before_filter=366 cells_after_filter=298 truncated=false bounds=51.4463,-0.0284,51.4875,0.0526 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=1.9 total_ms=6.5
+2026-03-15T19:54:34.842844Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2ec7ffff resolution=9 total_count=102 filters=1 filters_raw="Listing status:Historical sale" ms=0.1
+2026-03-15T19:54:35.089458Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=295 cells_after_filter=209 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=1.6 total_ms=5.0
+2026-03-15T19:54:36.130976Z INFO property_map_server::routes::properties: GET /api/hexagon-properties h3=89194ad2ec7ffff resolution=9 total=102 returned=100 offset=0 filters=1 filters_raw="Listing status:Historical sale" ms=0.4
+2026-03-15T19:54:38.679953Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2ec7ffff resolution=9 total_count=5 filters=1 filters_raw="Listing status:For sale" ms=0.1
+2026-03-15T19:54:38.832683Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=263 cells_after_filter=194 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.6 total_ms=2.4
+2026-03-15T19:55:05.573712Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2ec7ffff resolution=9 total_count=102 filters=1 filters_raw="Listing status:Historical sale" ms=0.1
+2026-03-15T19:55:06.053141Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=295 cells_after_filter=209 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=1.4 total_ms=4.8
+2026-03-15T19:55:06.289059Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2ec7ffff resolution=9 total_count=5 filters=1 filters_raw="Listing status:For sale" ms=0.1
+2026-03-15T19:55:06.464008Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=263 cells_after_filter=194 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.7 total_ms=2.4
+2026-03-15T19:55:07.257113Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=63016 parallel=true cells_before_filter=329 cells_after_filter=275 truncated=false bounds=51.4463,-0.0284,51.4875,0.0526 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.8 total_ms=3.6
+2026-03-15T19:55:10.176114Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2ecfffff resolution=9 total_count=20 filters=1 filters_raw="Listing status:For sale" ms=0.1
+2026-03-15T19:55:10.449590Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=263 cells_after_filter=194 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.8 total_ms=2.6
+2026-03-15T19:55:11.581365Z INFO property_map_server::routes::properties: GET /api/hexagon-properties h3=89194ad2ecfffff resolution=9 total=20 returned=20 offset=0 filters=1 filters_raw="Listing status:For sale" ms=0.3
+2026-03-15T19:55:12.826310Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2ecfffff resolution=9 total_count=1 filters=1 filters_raw="Listing status:For rent" ms=0.1
+2026-03-15T19:55:13.296370Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=69 cells_after_filter=54 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:For rent" travel_entries=0 agg_ms=0.4 total_ms=0.8
+2026-03-15T19:55:14.274431Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2ecfffff resolution=9 total_count=20 filters=1 filters_raw="Listing status:For sale" ms=0.2
+2026-03-15T19:55:14.745705Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=263 cells_after_filter=194 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.8 total_ms=2.6
+2026-03-15T19:55:15.410862Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=263 cells_after_filter=194 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.7 total_ms=2.6
+2026-03-15T19:55:15.973540Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2ecfffff resolution=9 total_count=285 filters=1 filters_raw="Listing status:Historical sale" ms=0.2
+2026-03-15T19:55:16.149036Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=295 cells_after_filter=209 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=1.6 total_ms=5.2
+2026-03-15T19:55:50.335806Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=63016 parallel=true cells_before_filter=366 cells_after_filter=298 truncated=false bounds=51.4463,-0.0284,51.4875,0.0526 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=1.8 total_ms=6.5
+2026-03-15T19:55:51.579153Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2e53ffff resolution=9 total_count=119 filters=1 filters_raw="Listing status:Historical sale" ms=0.1
+2026-03-15T19:55:51.847817Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=295 cells_after_filter=209 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:Historical sale" travel_entries=0 agg_ms=1.6 total_ms=4.8
+2026-03-15T19:57:08.597832Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=5 rows=14962301 parallel=true cells_before_filter=687 cells_after_filter=687 truncated=false bounds=46.0000,-12.0000,56.5000,12.0000 filters=0 filters_raw="-" travel_entries=0 agg_ms=206.2 total_ms=218.4
+2026-03-15T19:58:56.459660Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2e53ffff resolution=9 total_count=5 filters=1 filters_raw="Listing status:For sale" ms=0.1
+2026-03-15T19:58:56.611313Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=263 cells_after_filter=194 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.8 total_ms=2.8
+2026-03-15T19:58:58.208823Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=63016 parallel=true cells_before_filter=329 cells_after_filter=275 truncated=false bounds=51.4463,-0.0284,51.4875,0.0526 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=1.0 total_ms=4.1
+2026-03-15T19:58:59.418179Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2e5bffff resolution=9 total_count=5 filters=1 filters_raw="Listing status:For sale" ms=0.1
+2026-03-15T19:58:59.646741Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=51710 parallel=true cells_before_filter=263 cells_after_filter=194 truncated=false bounds=51.4463,-0.0153,51.4875,0.0394 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.7 total_ms=2.5
+2026-03-15T19:59:13.261566Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad2ecfffff resolution=9 total_count=20 filters=1 filters_raw="Listing status:For sale" ms=0.1
+2026-03-15T19:59:16.381213Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad216fffff resolution=9 total_count=2 filters=1 filters_raw="Listing status:For sale" ms=0.1
+2026-03-15T19:59:19.468079Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=65337 parallel=true cells_before_filter=263 cells_after_filter=187 truncated=false bounds=51.4575,-0.0360,51.4986,0.0187 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.8 total_ms=2.8
+2026-03-15T19:59:20.912780Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=57987 parallel=true cells_before_filter=230 cells_after_filter=192 truncated=false bounds=51.4560,-0.0422,51.4972,0.0126 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.6 total_ms=2.4
+2026-03-15T19:59:22.439657Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad04a7ffff resolution=9 total_count=45 filters=1 filters_raw="Listing status:For sale" ms=0.2
+2026-03-15T19:59:25.975325Z INFO property_map_server::routes::properties: GET /api/hexagon-properties h3=89194ad04a7ffff resolution=9 total=45 returned=45 offset=0 filters=1 filters_raw="Listing status:For sale" ms=0.4
+2026-03-15T19:59:44.130233Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=27673 parallel=false cells_before_filter=124 cells_after_filter=79 truncated=false bounds=51.4630,-0.0335,51.4874,-0.0010 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.6 total_ms=1.4
+2026-03-15T19:59:44.863513Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=23785 parallel=false cells_before_filter=105 cells_after_filter=82 truncated=false bounds=51.4612,-0.0378,51.4857,-0.0053 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.6 total_ms=1.4
+2026-03-15T19:59:45.429953Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=10363 parallel=false cells_before_filter=39 cells_after_filter=27 truncated=false bounds=51.4668,-0.0300,51.4794,-0.0133 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=0.2 total_ms=0.6
+2026-03-15T19:59:47.052444Z INFO property_map_server::routes::postcodes: GET /api/postcodes postcodes_before_filter=69 postcodes_after_filter=22 filtered_out=47 truncated=false bounds=51.469424,-0.026465,51.476579,-0.016948 filters=1 filters_raw="Listing status:For sale" travel_entries=0 total_ms=0.8
+2026-03-15T19:59:48.970011Z INFO property_map_server::routes::postcodes: GET /api/postcodes postcodes_before_filter=34 postcodes_after_filter=8 filtered_out=26 truncated=false bounds=51.470330,-0.025601,51.476006,-0.018052 filters=1 filters_raw="Listing status:For sale" travel_entries=0 total_ms=0.5
+2026-03-15T19:59:49.463765Z INFO property_map_server::routes::postcodes: GET /api/postcodes postcodes_before_filter=34 postcodes_after_filter=4 filtered_out=30 truncated=false bounds=51.471351,-0.024627,51.475359,-0.019295 filters=1 filters_raw="Listing status:For sale" travel_entries=0 total_ms=0.3
+2026-03-15T19:59:51.056899Z INFO property_map_server::routes::postcodes: GET /api/postcodes postcodes_before_filter=34 postcodes_after_filter=9 filtered_out=25 truncated=false bounds=51.471780,-0.026331,51.475789,-0.020998 filters=1 filters_raw="Listing status:For sale" travel_entries=0 total_ms=0.4
+2026-03-15T19:59:51.581180Z INFO property_map_server::routes::postcodes: GET /api/postcodes postcodes_before_filter=34 postcodes_after_filter=6 filtered_out=28 truncated=false bounds=51.471674,-0.025659,51.475683,-0.020327 filters=1 filters_raw="Listing status:For sale" travel_entries=0 total_ms=0.4
+2026-03-15T19:59:58.001176Z INFO property_map_server::routes::postcodes: GET /api/postcodes postcodes_before_filter=34 postcodes_after_filter=8 filtered_out=26 truncated=false bounds=51.471124,-0.025948,51.476103,-0.019326 filters=1 filters_raw="Listing status:For sale" travel_entries=0 total_ms=0.6
+2026-03-15T20:00:01.147529Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad04a7ffff resolution=9 total_count=44 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold|Leasehold" ms=0.2
+2026-03-15T20:00:01.649333Z INFO property_map_server::routes::postcodes: GET /api/postcodes postcodes_before_filter=33 postcodes_after_filter=7 filtered_out=26 truncated=false bounds=51.471124,-0.025948,51.476103,-0.019326 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold|Leasehold" travel_entries=0 total_ms=0.3
+2026-03-15T20:00:03.524035Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad04a7ffff resolution=9 total_count=1 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" ms=0.2
+2026-03-15T20:00:03.982950Z INFO property_map_server::routes::postcodes: GET /api/postcodes postcodes_before_filter=5 postcodes_after_filter=1 filtered_out=4 truncated=false bounds=51.471124,-0.025948,51.476103,-0.019326 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 total_ms=0.2
+2026-03-15T20:00:05.734272Z INFO property_map_server::routes::postcodes: GET /api/postcodes postcodes_before_filter=16 postcodes_after_filter=10 filtered_out=6 truncated=false bounds=51.468325,-0.029578,51.477865,-0.016889 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 total_ms=0.4
+2026-03-15T20:00:07.598747Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=371660 parallel=true cells_before_filter=1032 cells_after_filter=874 truncated=false bounds=51.4033,-0.1135,51.5201,0.0418 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 agg_ms=2.3 total_ms=9.7
+2026-03-15T20:00:07.902439Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=277117 parallel=true cells_before_filter=961 cells_after_filter=829 truncated=false bounds=51.4051,-0.0436,51.5218,0.1117 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 agg_ms=2.0 total_ms=9.7
+2026-03-15T20:00:08.868329Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=410619 parallel=true cells_before_filter=1446 cells_after_filter=1288 truncated=false bounds=51.3867,-0.0636,51.5340,0.1322 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 agg_ms=2.5 total_ms=14.6
+2026-03-15T20:00:10.739310Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad04a7ffff resolution=9 total_count=38 filters=2 filters_raw="Listing status:Historical sale;;Leasehold/Freehold:Freehold" ms=0.2
+2026-03-15T20:00:11.236022Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=410619 parallel=true cells_before_filter=2260 cells_after_filter=1984 truncated=false bounds=51.3867,-0.0636,51.5340,0.1322 filters=2 filters_raw="Listing status:Historical sale;;Leasehold/Freehold:Freehold" travel_entries=0 agg_ms=5.3 total_ms=38.1
+2026-03-15T20:00:12.173963Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad04a7ffff resolution=9 total_count=1 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" ms=0.2
+2026-03-15T20:00:12.666440Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=410619 parallel=true cells_before_filter=1446 cells_after_filter=1288 truncated=false bounds=51.3867,-0.0636,51.5340,0.1322 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 agg_ms=2.6 total_ms=15.7
+2026-03-15T20:00:19.409064Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad04a7ffff resolution=9 total_count=0 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1519231" ms=0.2
+2026-03-15T20:00:19.968653Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=571405 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.3867,-0.1106,51.5340,0.1792 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1519231" travel_entries=0 agg_ms=2.2 total_ms=2.2
+2026-03-15T20:00:20.722189Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=571405 parallel=true cells_before_filter=1971 cells_after_filter=1801 truncated=false bounds=51.3867,-0.1106,51.5340,0.1792 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 agg_ms=3.7 total_ms=6.2
+2026-03-15T20:00:21.190329Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=571405 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.3867,-0.1106,51.5340,0.1792 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1519231" travel_entries=0 agg_ms=2.3 total_ms=2.3
+2026-03-15T20:00:27.942711Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=571405 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.3867,-0.1106,51.5340,0.1792 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:9200" travel_entries=0 agg_ms=2.1 total_ms=2.1
+2026-03-15T20:00:29.461340Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=525321 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.4145,-0.0678,51.5616,0.2220 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:9200" travel_entries=0 agg_ms=1.9 total_ms=1.9
+2026-03-15T20:00:31.709909Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=525321 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.4145,-0.0678,51.5616,0.2220 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:9200" travel_entries=0 agg_ms=2.1 total_ms=2.1
+2026-03-15T20:00:32.512895Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=525321 parallel=true cells_before_filter=1836 cells_after_filter=1678 truncated=false bounds=51.4145,-0.0678,51.5616,0.2220 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 agg_ms=3.2 total_ms=5.6
+2026-03-15T20:00:33.940425Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=525321 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.4145,-0.0678,51.5616,0.2220 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1700" travel_entries=0 agg_ms=1.6 total_ms=1.6
+2026-03-15T20:00:34.849329Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3141817 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.1389,-0.4994,51.7648,0.7321 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1700" travel_entries=0 agg_ms=14.9 total_ms=14.9
+2026-03-15T20:00:35.804047Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3560390 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.0590,-0.6228,51.8363,0.9068 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1700" travel_entries=0 agg_ms=9.3 total_ms=9.3
+2026-03-15T20:00:36.131775Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3610110 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.1661,-0.6093,51.9416,0.9202 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1700" travel_entries=0 agg_ms=9.3 total_ms=9.3
+2026-03-15T20:00:37.313585Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3610110 parallel=true cells_before_filter=1788 cells_after_filter=1780 truncated=false bounds=51.1661,-0.6093,51.9416,0.9202 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 agg_ms=11.8 total_ms=14.2
+2026-03-15T20:00:40.426451Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3610110 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.1661,-0.6093,51.9416,0.9202 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1519231" travel_entries=0 agg_ms=10.8 total_ms=10.8
+2026-03-15T20:00:42.361059Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1519231" travel_entries=0 agg_ms=9.4 total_ms=9.4
+2026-03-15T20:00:43.409927Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=1193 cells_after_filter=1182 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 agg_ms=10.5 total_ms=12.0
+2026-03-15T20:00:46.333117Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1519231" travel_entries=0 agg_ms=8.8 total_ms=8.8
+2026-03-15T20:00:48.082494Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1519231" travel_entries=0 agg_ms=8.4 total_ms=8.4
+2026-03-15T20:00:49.833842Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=1193 cells_after_filter=1182 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 agg_ms=10.1 total_ms=23.3
+2026-03-15T20:01:02.011997Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=1094 cells_after_filter=1088 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:8855327" travel_entries=0 agg_ms=11.0 total_ms=21.9
+2026-03-15T20:01:03.418647Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=1193 cells_after_filter=1182 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=2 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold" travel_entries=0 agg_ms=11.4 total_ms=13.1
+2026-03-15T20:01:03.571497Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=1094 cells_after_filter=1088 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:8855327" travel_entries=0 agg_ms=11.2 total_ms=12.9
+2026-03-15T20:01:08.874337Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=1054 cells_after_filter=1048 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:204" travel_entries=0 agg_ms=12.2 total_ms=23.6
+2026-03-15T20:01:14.467687Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=4 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:204;;Est. price per sqm:20:918535" travel_entries=0 agg_ms=9.8 total_ms=9.8
+2026-03-15T20:01:15.872578Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=1054 cells_after_filter=1048 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:204" travel_entries=0 agg_ms=12.0 total_ms=13.6
+2026-03-15T20:01:16.342009Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=4 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:204;;Est. price per sqm:20:918535" travel_entries=0 agg_ms=10.0 total_ms=10.0
+2026-03-15T20:01:18.114175Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2300,-0.4515,51.8453,0.7617 filters=4 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:204;;Est. price per sqm:20:918535" travel_entries=0 agg_ms=10.2 total_ms=10.2
+2026-03-15T20:01:40.296993Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3094495 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2300,-0.4517,51.8453,0.7619 filters=4 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:204;;Est. price per sqm:20:918535" travel_entries=0 agg_ms=9.8 total_ms=9.8
+2026-03-15T20:02:48.056601Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3111647 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2295,-0.4528,51.8458,0.7630 filters=4 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:204;;Est. price per sqm:20:918535" travel_entries=0 agg_ms=9.6 total_ms=9.6
+2026-03-15T20:02:49.785245Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3349513 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2195,-0.5956,51.8557,0.9057 filters=4 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:204;;Est. price per sqm:20:918535" travel_entries=0 agg_ms=9.4 total_ms=9.4
+2026-03-15T20:03:02.786058Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3349513 parallel=true cells_before_filter=1285 cells_after_filter=1278 truncated=false bounds=51.2195,-0.5956,51.8557,0.9057 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:204" travel_entries=0 agg_ms=13.1 total_ms=26.2
+2026-03-15T20:03:23.742245Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3349513 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2195,-0.5956,51.8557,0.9057 filters=4 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Total floor area (sqm):0.1:204;;Price per sqm:5:1519231" travel_entries=0 agg_ms=10.0 total_ms=10.0
+2026-03-15T20:03:30.904544Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3349513 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2195,-0.5956,51.8557,0.9057 filters=3 filters_raw="Listing status:For sale;;Leasehold/Freehold:Freehold;;Price per sqm:5:1519231" travel_entries=0 agg_ms=9.0 total_ms=9.0
+2026-03-15T20:03:31.700115Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3349513 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2195,-0.5956,51.8557,0.9057 filters=2 filters_raw="Listing status:For sale;;Price per sqm:5:1519231" travel_entries=0 agg_ms=9.5 total_ms=9.5
+2026-03-15T20:03:33.123759Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3349513 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2195,-0.5956,51.8557,0.9057 filters=2 filters_raw="Listing status:For sale;;Price per sqm:5:1519231" travel_entries=0 agg_ms=10.7 total_ms=10.7
+2026-03-15T20:03:33.264092Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3349513 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2195,-0.5956,51.8557,0.9057 filters=2 filters_raw="Listing status:For sale;;Price per sqm:5:2500" travel_entries=0 agg_ms=8.3 total_ms=8.3
+2026-03-15T20:03:34.235435Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3349513 parallel=true cells_before_filter=1479 cells_after_filter=1465 truncated=false bounds=51.2195,-0.5956,51.8557,0.9057 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=10.7 total_ms=12.9
+2026-03-15T20:03:34.567324Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3349513 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2195,-0.5956,51.8557,0.9057 filters=2 filters_raw="Listing status:For sale;;Price per sqm:1400:2500" travel_entries=0 agg_ms=9.0 total_ms=9.0
+2026-03-15T20:03:36.096362Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=3349513 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=51.2195,-0.5956,51.8557,0.9057 filters=2 filters_raw="Listing status:For sale;;Price per sqm:1400:2500" travel_entries=0 agg_ms=7.6 total_ms=7.6
+2026-03-15T20:03:37.751074Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=4814137 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=50.9471,-0.9801,52.1136,1.7728 filters=2 filters_raw="Listing status:For sale;;Price per sqm:1400:2500" travel_entries=0 agg_ms=11.8 total_ms=11.8
+2026-03-15T20:03:38.835277Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Price per sqm:1400:2500" travel_entries=0 agg_ms=56.1 total_ms=56.1
+2026-03-15T20:03:40.887729Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Price per sqm:1400:2500" travel_entries=0 agg_ms=49.8 total_ms=49.8
+2026-03-15T20:03:42.529302Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3274 cells_after_filter=3270 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:Historical sale;;Price per sqm:1400:2500" travel_entries=0 agg_ms=84.4 total_ms=146.3
+2026-03-15T20:03:44.390098Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=0 cells_after_filter=0 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Price per sqm:1400:2500" travel_entries=0 agg_ms=54.6 total_ms=54.6
+2026-03-15T20:04:33.371452Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:04:33.371601Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:04:33.371608Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:04:33.457625Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:04:33.457635Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:04:35.974733Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:04:35.974742Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:04:36.398745Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:04:36.398757Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:04:36.476688Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:04:36.476699Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:04:46.252075Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T20:04:46.252184Z INFO property_map_server::data::property: Feature columns from config numeric=54 enums=13 total=67
+2026-03-15T20:04:47.610246Z INFO property_map_server::data::property: Combined data selected rows=15702691
+2026-03-15T20:04:47.804418Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-15T20:04:48.220314Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-15T20:04:49.335558Z INFO property_map_server::data::property: Extracting string columns
+2026-03-15T20:04:51.577686Z INFO property_map_server::data::property: Building enum features
+2026-03-15T20:04:53.025870Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-15T20:04:55.151481Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-15T20:04:55.151490Z INFO property_map_server::data::property: Extracting listing features
+2026-03-15T20:04:55.671708Z INFO property_map_server::data::property: Listing features extracted properties_with_features=412749
+2026-03-15T20:04:55.671716Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-15T20:04:56.573495Z INFO property_map_server::data::property: Building interned strings
+2026-03-15T20:05:02.540797Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-15T20:05:05.103322Z INFO property_map_server::data::property: Data loading complete
+2026-03-15T20:05:06.794645Z INFO property_map_server: Property data loaded rows=15702691 features=67 enums=13
+2026-03-15T20:05:06.794655Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-15T20:05:06.892926Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-15T20:05:06.892936Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-15T20:05:07.310766Z INFO property_map_server::data::property: H3 precomputation complete (15702691 cells)
+2026-03-15T20:05:07.310799Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-15T20:05:07.310815Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-15T20:05:07.355286Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-15T20:05:07.468648Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-15T20:05:07.469230Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-15T20:05:07.506618Z INFO property_map_server: POI data loaded pois=678242
+2026-03-15T20:05:07.506627Z INFO property_map_server: Building POI spatial grid index
+2026-03-15T20:05:07.512084Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-15T20:05:07.512091Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-15T20:05:07.525343Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-15T20:05:07.526115Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-15T20:05:07.526170Z INFO property_map_server: Place data loaded places=3474
+2026-03-15T20:05:07.526181Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-15T20:05:07.526186Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-15T20:05:07.536262Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-15T20:05:15.228511Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-15T20:05:15.228521Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-15T20:05:15.228532Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-15T20:05:15.231530Z INFO property_map_server: PMTiles loaded successfully
+2026-03-15T20:05:15.273794Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-15T20:05:15.309107Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-15T20:05:15.309278Z INFO property_map_server: Precomputed features response groups=9
+2026-03-15T20:05:15.309292Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-15T20:05:15.446941Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-15T20:05:15.454051Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-15T20:05:15.459669Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-15T20:05:15.591403Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
+2026-03-15T20:05:15.597637Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-15T20:05:15.597658Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-15T20:05:15.597670Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-15T20:05:15.643613Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-15T20:05:15.644670Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-15T20:05:15.646043Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-15T20:05:15.653830Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1849
+2026-03-15T20:05:15.653847Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-15T20:05:15.653891Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-15T20:05:19.156377Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-15T20:05:19.156422Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-15T20:05:27.513039Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:05:27.517384Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:05:27.524994Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:05:27.531774Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:05:27.537007Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:05:27.542722Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:05:27.549237Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:05:27.563523Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:05:28.096739Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:05:28.096753Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:06:11.902556Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3248 cells_after_filter=3246 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=55.7 total_ms=84.8
+2026-03-15T20:08:11.734568Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3113 cells_after_filter=3112 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):0.1:8855327" travel_entries=0 agg_ms=56.9 total_ms=83.5
+2026-03-15T20:08:12.991872Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3248 cells_after_filter=3246 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=70.2 total_ms=76.3
+2026-03-15T20:08:13.507957Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3113 cells_after_filter=3112 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):0.1:8855327" travel_entries=0 agg_ms=59.5 total_ms=63.9
+2026-03-15T20:08:19.338486Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3113 cells_after_filter=3112 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):0.1:8855327" travel_entries=0 agg_ms=74.5 total_ms=102.6
+2026-03-15T20:08:20.686317Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3248 cells_after_filter=3246 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=55.0 total_ms=59.5
+2026-03-15T20:08:20.922174Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3113 cells_after_filter=3112 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):0.1:8855327" travel_entries=0 agg_ms=60.6 total_ms=65.2
+2026-03-15T20:08:26.772910Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3082 cells_after_filter=3081 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):0.1:788" travel_entries=0 agg_ms=62.6 total_ms=89.4
+2026-03-15T20:08:28.805969Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3248 cells_after_filter=3246 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=59.6 total_ms=64.4
+2026-03-15T20:08:28.952002Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3082 cells_after_filter=3081 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):0.1:788" travel_entries=0 agg_ms=57.7 total_ms=62.3
+2026-03-15T20:08:30.558244Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3113 cells_after_filter=3112 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):0.1:8855327" travel_entries=0 agg_ms=58.2 total_ms=84.6
+2026-03-15T20:08:33.038408Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3248 cells_after_filter=3246 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=56.6 total_ms=61.3
+2026-03-15T20:08:33.304924Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=3113 cells_after_filter=3112 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):0.1:8855327" travel_entries=0 agg_ms=59.1 total_ms=63.7
+2026-03-15T20:08:34.406141Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=13490411 parallel=true cells_before_filter=1106 cells_after_filter=1106 truncated=false bounds=49.0536,-4.4123,53.8271,6.8427 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):833:8855327" travel_entries=0 agg_ms=48.2 total_ms=55.7
+2026-03-15T20:08:36.292323Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=6 rows=9738472 parallel=true cells_before_filter=765 cells_after_filter=765 truncated=false bounds=49.7628,-3.2615,52.9110,4.1398 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):833:8855327" travel_entries=0 agg_ms=36.2 total_ms=41.7
+2026-03-15T20:08:39.628619Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=8 rows=2480095 parallel=true cells_before_filter=165 cells_after_filter=164 truncated=false bounds=51.3348,-0.4935,51.7042,0.3778 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):833:8855327" travel_entries=0 agg_ms=5.9 total_ms=7.2
+2026-03-15T20:08:41.653852Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=88195da457fffff resolution=8 total_count=18 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):833:8855327" ms=0.1
+2026-03-15T20:08:41.915336Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=8 rows=2176649 parallel=true cells_before_filter=139 cells_after_filter=139 truncated=false bounds=51.3348,-0.3793,51.7042,0.2636 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):833:8855327" travel_entries=0 agg_ms=5.4 total_ms=6.5
+2026-03-15T20:08:44.824263Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=40 cells_after_filter=37 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):833:8855327" travel_entries=0 agg_ms=1.1 total_ms=1.4
+2026-03-15T20:08:45.089912Z INFO property_map_server::routes::properties: GET /api/hexagon-properties h3=88195da457fffff resolution=8 total=18 returned=18 offset=0 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):833:8855327" ms=0.2
+2026-03-15T20:09:25.568766Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1197 cells_after_filter=1045 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=2.1 total_ms=3.8
+2026-03-15T20:09:26.074762Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=40 cells_after_filter=37 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):833:8855327" travel_entries=0 agg_ms=1.2 total_ms=1.3
+2026-03-15T20:09:28.666758Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=88195da457fffff resolution=8 total_count=53 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):106:8855327" ms=0.1
+2026-03-15T20:09:29.155687Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1096 cells_after_filter=963 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):106:8855327" travel_entries=0 agg_ms=2.6 total_ms=12.1
+2026-03-15T20:09:29.978092Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1197 cells_after_filter=1045 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=2.1 total_ms=3.8
+2026-03-15T20:09:30.496935Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1096 cells_after_filter=963 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):106:8855327" travel_entries=0 agg_ms=2.1 total_ms=3.7
+2026-03-15T20:09:33.901621Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=88195da457fffff resolution=8 total_count=19 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):106:204" ms=0.1
+2026-03-15T20:09:34.385794Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1081 cells_after_filter=949 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):106:204" travel_entries=0 agg_ms=2.4 total_ms=10.3
+2026-03-15T20:09:35.102671Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1197 cells_after_filter=1045 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=2.2 total_ms=3.9
+2026-03-15T20:09:35.587723Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1081 cells_after_filter=949 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):106:204" travel_entries=0 agg_ms=2.2 total_ms=3.7
+2026-03-15T20:09:36.148540Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=88195da457fffff resolution=8 total_count=19 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):98:204" ms=0.1
+2026-03-15T20:09:36.644673Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1081 cells_after_filter=949 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):98:204" travel_entries=0 agg_ms=2.5 total_ms=10.4
+2026-03-15T20:09:37.160139Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1197 cells_after_filter=1045 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=2.2 total_ms=3.9
+2026-03-15T20:09:37.683238Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1081 cells_after_filter=949 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):98:204" travel_entries=0 agg_ms=2.2 total_ms=3.8
+2026-03-15T20:09:37.944776Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=88195da457fffff resolution=8 total_count=19 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):89:204" ms=0.1
+2026-03-15T20:09:37.969098Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1081 cells_after_filter=949 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):89:204" travel_entries=0 agg_ms=2.2 total_ms=10.4
+2026-03-15T20:09:42.074237Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1197 cells_after_filter=1045 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=1 filters_raw="Listing status:For sale" travel_entries=0 agg_ms=2.1 total_ms=4.2
+2026-03-15T20:09:42.532178Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1081 cells_after_filter=949 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):89:204" travel_entries=0 agg_ms=2.4 total_ms=4.0
+2026-03-15T20:09:50.016920Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=88195da457fffff resolution=8 total_count=19 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):106:204" ms=0.1
+2026-03-15T20:09:50.508364Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=308665 parallel=true cells_before_filter=1081 cells_after_filter=949 truncated=false bounds=51.5155,-0.2104,51.6023,-0.0591 filters=2 filters_raw="Listing status:For sale;;Total floor area (sqm):106:204" travel_entries=0 agg_ms=2.2 total_ms=10.2
+2026-03-15T20:10:13.683691Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:10:13.683848Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:10:13.683854Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:10:13.750258Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:10:13.750268Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:10:16.179096Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:10:16.179106Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:10:16.456525Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:10:16.456537Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:10:16.514061Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:10:16.514070Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:10:22.681306Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T20:10:22.681402Z INFO property_map_server::data::property: Feature columns from config numeric=54 enums=13 total=67
+2026-03-15T20:10:24.012054Z INFO property_map_server::data::property: Combined data selected rows=15702691
+2026-03-15T20:10:24.180353Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-15T20:10:24.607077Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-15T20:10:25.773925Z INFO property_map_server::data::property: Extracting string columns
+2026-03-15T20:10:27.857482Z INFO property_map_server::data::property: Building enum features
+2026-03-15T20:10:29.227608Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-15T20:10:31.336600Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-15T20:10:31.336609Z INFO property_map_server::data::property: Extracting listing features
+2026-03-15T20:10:31.843715Z INFO property_map_server::data::property: Listing features extracted properties_with_features=412749
+2026-03-15T20:10:31.843723Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-15T20:10:32.766778Z INFO property_map_server::data::property: Building interned strings
+2026-03-15T20:10:38.715142Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-15T20:10:41.203246Z INFO property_map_server::data::property: Data loading complete
+2026-03-15T20:10:42.829684Z INFO property_map_server: Property data loaded rows=15702691 features=67 enums=13
+2026-03-15T20:10:42.829695Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-15T20:10:42.925550Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-15T20:10:42.925560Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-15T20:10:43.323292Z INFO property_map_server::data::property: H3 precomputation complete (15702691 cells)
+2026-03-15T20:10:43.323313Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-15T20:10:43.323319Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-15T20:10:43.341356Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-15T20:10:43.453718Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-15T20:10:43.454297Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-15T20:10:43.490938Z INFO property_map_server: POI data loaded pois=678242
+2026-03-15T20:10:43.490947Z INFO property_map_server: Building POI spatial grid index
+2026-03-15T20:10:43.496143Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-15T20:10:43.496149Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-15T20:10:43.496678Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-15T20:10:43.497419Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-15T20:10:43.497476Z INFO property_map_server: Place data loaded places=3474
+2026-03-15T20:10:43.497486Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-15T20:10:43.497491Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-15T20:10:43.498313Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-15T20:10:50.089457Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-15T20:10:50.089468Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-15T20:10:50.089483Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-15T20:10:50.089693Z INFO property_map_server: PMTiles loaded successfully
+2026-03-15T20:10:50.134239Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-15T20:10:50.160720Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-15T20:10:50.160908Z INFO property_map_server: Precomputed features response groups=9
+2026-03-15T20:10:50.160921Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-15T20:10:50.220618Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-15T20:10:50.224719Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-15T20:10:50.247220Z INFO property_map_server::pocketbase: Added notes text field to PocketBase collection 'saved_searches'
+2026-03-15T20:10:50.251061Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-15T20:10:50.257749Z INFO property_map_server::pocketbase: Added notes text field to PocketBase collection 'saved_properties'
+2026-03-15T20:10:50.304219Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
+2026-03-15T20:10:50.308723Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-15T20:10:50.308749Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-15T20:10:50.308761Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-15T20:10:50.314963Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-15T20:10:50.315108Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-15T20:10:50.315266Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-15T20:10:50.316776Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1849
+2026-03-15T20:10:50.316796Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-15T20:10:50.316843Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-15T20:10:51.706624Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-15T20:10:51.706663Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-15T20:10:52.074357Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:10:52.074443Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:10:52.507461Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:10:52.508607Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:10:52.516615Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:10:52.522899Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:10:52.536710Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:10:52.541257Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:10:52.549244Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:10:52.550031Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:18:43.657651Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:18:43.657816Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:18:43.657822Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:18:43.746197Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:18:43.746208Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:18:46.373581Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:18:46.373592Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:18:46.679114Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:18:46.679124Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:18:46.747208Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:18:46.747221Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:19:00.212275Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:19:00.212447Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:19:00.212456Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:19:00.289849Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:19:00.289859Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:19:02.760385Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:19:02.760396Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:19:03.052345Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:19:03.052355Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:19:03.116051Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:19:03.116060Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:19:09.885025Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T20:19:09.885115Z INFO property_map_server::data::property: Feature columns from config numeric=54 enums=13 total=67
+2026-03-15T20:19:11.176403Z INFO property_map_server::data::property: Combined data selected rows=15702691
+2026-03-15T20:19:11.363917Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-15T20:19:11.771906Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-15T20:19:13.025850Z INFO property_map_server::data::property: Extracting string columns
+2026-03-15T20:19:15.244059Z INFO property_map_server::data::property: Building enum features
+2026-03-15T20:19:16.598869Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-15T20:19:18.813936Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-15T20:19:18.813945Z INFO property_map_server::data::property: Extracting listing features
+2026-03-15T20:19:19.353132Z INFO property_map_server::data::property: Listing features extracted properties_with_features=412749
+2026-03-15T20:19:19.353141Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-15T20:19:20.231462Z INFO property_map_server::data::property: Building interned strings
+2026-03-15T20:19:26.257466Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-15T20:19:28.766985Z INFO property_map_server::data::property: Data loading complete
+2026-03-15T20:19:30.388072Z INFO property_map_server: Property data loaded rows=15702691 features=67 enums=13
+2026-03-15T20:19:30.388081Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-15T20:19:30.483869Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-15T20:19:30.483878Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-15T20:19:30.866157Z INFO property_map_server::data::property: H3 precomputation complete (15702691 cells)
+2026-03-15T20:19:30.866199Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-15T20:19:30.866214Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-15T20:19:30.902414Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-15T20:19:31.015549Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-15T20:19:31.016109Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-15T20:19:31.054466Z INFO property_map_server: POI data loaded pois=678242
+2026-03-15T20:19:31.054474Z INFO property_map_server: Building POI spatial grid index
+2026-03-15T20:19:31.059717Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-15T20:19:31.059723Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-15T20:19:31.069876Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-15T20:19:31.070582Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-15T20:19:31.070632Z INFO property_map_server: Place data loaded places=3474
+2026-03-15T20:19:31.070640Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-15T20:19:31.070644Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-15T20:19:31.079677Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-15T20:19:39.095251Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-15T20:19:39.095261Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-15T20:19:39.095277Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-15T20:19:39.185235Z INFO property_map_server: PMTiles loaded successfully
+2026-03-15T20:19:39.228620Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-15T20:19:39.315674Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-15T20:19:39.315892Z INFO property_map_server: Precomputed features response groups=9
+2026-03-15T20:19:39.315908Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-15T20:19:39.370690Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-15T20:19:39.373624Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-15T20:19:39.378395Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-15T20:19:39.427367Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
+2026-03-15T20:19:39.430988Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-15T20:19:39.431004Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-15T20:19:39.431017Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-15T20:19:39.437636Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-15T20:19:39.437807Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-15T20:19:39.437966Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-15T20:19:39.439692Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1849
+2026-03-15T20:19:39.439715Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-15T20:19:39.439777Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-15T20:19:42.954025Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-15T20:19:42.954067Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-15T20:19:43.261880Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:19:43.263169Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:43.749947Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:43.751657Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:44.102549Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:44.111787Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:44.529068Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:44.529991Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:44.546765Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:44.551558Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:44.558857Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:44.565720Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:44.574121Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:44.577816Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:50.493074Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:50.493085Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:50.514758Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:50.515163Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:50.526484Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:50.530275Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:50.535884Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:50.542453Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:50.551188Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:20:50.556666Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:51.102224Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T20:20:51.110261Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T20:21:45.349498Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:21:45.349655Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:21:45.349664Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:21:45.421388Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:21:45.421400Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:21:47.937781Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:21:47.937791Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:21:48.227331Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:21:48.227342Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:21:48.286224Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:21:48.286233Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:22:05.552980Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T20:31:37.445987Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:31:37.446148Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:31:37.446155Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:31:37.534049Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:31:37.534061Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:31:40.177015Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:31:40.177028Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:31:40.482618Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:31:40.482630Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:31:40.546018Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:31:40.546027Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:31:53.986877Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:31:53.987039Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:31:53.987045Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:31:54.063239Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:31:54.063248Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:31:56.648053Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:31:56.648065Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:31:56.965183Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:31:56.965194Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:31:57.027327Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:31:57.027342Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:31:59.916992Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T20:32:04.424692Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:32:04.424881Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:32:04.424890Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:32:04.493173Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:32:04.493184Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:32:07.324815Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:32:07.324827Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:32:07.628171Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:32:07.628182Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:32:07.697976Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:32:07.697987Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:32:09.987127Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T20:32:15.239857Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:32:15.240016Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:32:15.240027Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:32:15.312610Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:32:15.312619Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:32:17.889502Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:32:17.889512Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:32:18.181929Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:32:18.181939Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:32:18.245757Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:32:18.245767Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:32:23.084864Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:32:23.085017Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:32:23.085025Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:32:23.149174Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:32:23.149184Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:32:25.785485Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:32:25.785496Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:32:26.076631Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:32:26.076644Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:32:26.135954Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:32:26.135967Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:32:28.972888Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T20:42:17.088723Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:42:17.088899Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:42:17.088907Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:42:17.150999Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:42:17.151009Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:42:19.827707Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:42:19.827719Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:42:20.135500Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:42:20.135509Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:42:20.197192Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:42:20.197202Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:42:56.103982Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:42:56.104138Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:42:56.104143Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:42:56.204428Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:42:56.204439Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:42:58.832976Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:42:58.832987Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:42:59.132876Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:42:59.132886Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:42:59.192137Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:42:59.192148Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:43:26.892462Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T20:56:11.923543Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:56:11.923747Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:56:11.923760Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:56:12.004141Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:56:12.004153Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:56:18.202087Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:56:18.202098Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:56:18.603337Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:56:18.603351Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:56:18.676290Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:56:18.676299Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:56:51.555700Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T20:56:51.555882Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T20:56:51.555890Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T20:56:51.649380Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T20:56:51.649390Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T20:56:54.479400Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T20:56:54.479413Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T20:56:54.787050Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T20:56:54.787063Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T20:56:54.853848Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T20:56:54.853857Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T20:57:26.731834Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T21:00:33.407080Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T21:00:33.407232Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T21:00:33.407238Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T21:00:33.499072Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T21:00:33.499081Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T21:00:36.274914Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T21:00:36.274924Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T21:00:36.575562Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T21:00:36.575572Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T21:00:36.638808Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T21:00:36.638817Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T21:00:50.949722Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T21:03:11.209421Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T21:03:11.209606Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T21:03:11.209612Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T21:03:11.289498Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T21:03:11.289508Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T21:03:13.875043Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T21:03:13.875053Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T21:03:14.208971Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T21:03:14.208982Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T21:03:14.272084Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T21:03:14.272095Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T21:03:28.932912Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T21:03:28.933073Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T21:03:28.933082Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T21:03:29.004139Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T21:03:29.004150Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T21:03:31.538874Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T21:03:31.538884Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T21:03:31.819659Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T21:03:31.819669Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T21:03:31.879197Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T21:03:31.879206Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T21:03:46.003768Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T21:19:16.107508Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T21:19:16.107694Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T21:19:16.107702Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T21:19:16.272588Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T21:19:16.272599Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T21:19:22.944816Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T21:19:22.944828Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T21:19:23.587534Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T21:19:23.589329Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T21:19:23.673638Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T21:19:23.673648Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T21:20:10.134367Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T21:20:10.173078Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
+2026-03-15T21:20:11.640174Z INFO property_map_server::data::property: Combined data selected rows=15702691
+2026-03-15T21:20:11.825306Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-15T21:20:12.283833Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-15T21:20:13.735551Z INFO property_map_server::data::property: Extracting string columns
+2026-03-15T21:20:16.091555Z INFO property_map_server::data::property: Building enum features
+2026-03-15T21:20:17.505895Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-15T21:20:19.730770Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-15T21:20:19.730780Z INFO property_map_server::data::property: Extracting listing features
+2026-03-15T21:20:20.299294Z INFO property_map_server::data::property: Listing features extracted properties_with_features=412749
+2026-03-15T21:20:20.299302Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-15T21:20:21.284951Z INFO property_map_server::data::property: Building interned strings
+2026-03-15T21:20:27.822185Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-15T21:20:30.681373Z INFO property_map_server::data::property: Data loading complete
+2026-03-15T21:20:32.249224Z INFO property_map_server: Property data loaded rows=15702691 features=68 enums=13
+2026-03-15T21:20:32.249237Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-15T21:20:32.646329Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-15T21:20:32.646339Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-15T21:20:33.084366Z INFO property_map_server::data::property: H3 precomputation complete (15702691 cells)
+2026-03-15T21:20:33.084392Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-15T21:20:33.084431Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-15T21:20:33.115170Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-15T21:20:33.231940Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-15T21:20:33.232512Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-15T21:20:33.270242Z INFO property_map_server: POI data loaded pois=678242
+2026-03-15T21:20:33.270252Z INFO property_map_server: Building POI spatial grid index
+2026-03-15T21:20:33.275905Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-15T21:20:33.275913Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-15T21:20:33.280404Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-15T21:20:33.281434Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-15T21:20:33.281491Z INFO property_map_server: Place data loaded places=3474
+2026-03-15T21:20:33.281502Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-15T21:20:33.281509Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-15T21:20:33.282358Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-15T21:20:41.788648Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-15T21:20:42.014614Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-15T21:20:42.014635Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-15T21:20:42.107058Z INFO property_map_server: PMTiles loaded successfully
+2026-03-15T21:20:42.150975Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-15T21:20:42.241257Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-15T21:20:42.241801Z INFO property_map_server: Precomputed features response groups=8
+2026-03-15T21:20:42.241820Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-15T21:20:42.361970Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-15T21:20:42.370041Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-15T21:20:42.375199Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-15T21:20:42.642209Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
+2026-03-15T21:20:42.651503Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-15T21:20:42.651536Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-15T21:20:42.651550Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-15T21:20:42.658771Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-15T21:20:42.658942Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-15T21:20:42.659104Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-15T21:20:42.661000Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1849
+2026-03-15T21:20:42.661031Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-15T21:20:42.661088Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-15T21:20:43.239746Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:20:47.481371Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-15T21:20:47.481419Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-15T21:21:42.708428Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:22:42.708082Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:23:42.708266Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:24:42.718391Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:25:41.332330Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T21:25:41.332500Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T21:25:41.332508Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T21:25:41.432307Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T21:25:41.432318Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T21:25:44.304936Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T21:25:44.304945Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T21:25:44.611586Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T21:25:44.613334Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T21:25:44.675390Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T21:25:44.675399Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T21:26:00.295681Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T21:26:00.312456Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
+2026-03-15T21:26:01.704637Z INFO property_map_server::data::property: Combined data selected rows=15702691
+2026-03-15T21:26:01.872395Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-15T21:26:02.248689Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-15T21:26:03.562896Z INFO property_map_server::data::property: Extracting string columns
+2026-03-15T21:26:05.837707Z INFO property_map_server::data::property: Building enum features
+2026-03-15T21:26:07.241019Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-15T21:26:09.414029Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-15T21:26:09.414038Z INFO property_map_server::data::property: Extracting listing features
+2026-03-15T21:26:09.970429Z INFO property_map_server::data::property: Listing features extracted properties_with_features=412749
+2026-03-15T21:26:09.970438Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-15T21:26:10.866902Z INFO property_map_server::data::property: Building interned strings
+2026-03-15T21:26:17.086286Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-15T21:26:19.719576Z INFO property_map_server::data::property: Data loading complete
+2026-03-15T21:26:21.461788Z INFO property_map_server: Property data loaded rows=15702691 features=68 enums=13
+2026-03-15T21:26:21.461797Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-15T21:26:21.564319Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-15T21:26:21.564329Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-15T21:26:21.994793Z INFO property_map_server::data::property: H3 precomputation complete (15702691 cells)
+2026-03-15T21:26:21.994838Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-15T21:26:21.994845Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-15T21:26:22.018732Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-15T21:26:22.133349Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-15T21:26:22.133928Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-15T21:26:22.171652Z INFO property_map_server: POI data loaded pois=678242
+2026-03-15T21:26:22.171659Z INFO property_map_server: Building POI spatial grid index
+2026-03-15T21:26:22.177174Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-15T21:26:22.177185Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-15T21:26:22.178952Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-15T21:26:22.179710Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-15T21:26:22.179774Z INFO property_map_server: Place data loaded places=3474
+2026-03-15T21:26:22.179781Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-15T21:26:22.179785Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-15T21:26:22.183476Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-15T21:26:30.728685Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-15T21:26:30.967914Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-15T21:26:30.967934Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-15T21:26:31.095450Z INFO property_map_server: PMTiles loaded successfully
+2026-03-15T21:26:31.145691Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-15T21:26:31.291690Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-15T21:26:31.292434Z INFO property_map_server: Precomputed features response groups=8
+2026-03-15T21:26:31.292449Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-15T21:26:31.424001Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-15T21:26:31.435471Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-15T21:26:31.438556Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-15T21:26:31.487488Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
+2026-03-15T21:26:31.492080Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-15T21:26:31.492166Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-15T21:26:31.492182Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-15T21:26:31.552671Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-15T21:26:31.566751Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-15T21:26:31.583207Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-15T21:26:31.611276Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1849
+2026-03-15T21:26:31.611304Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-15T21:26:31.611349Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-15T21:26:32.183021Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:26:36.065617Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-15T21:26:36.065653Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-15T21:26:39.187390Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T21:26:39.192134Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T21:26:39.203820Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T21:26:39.206632Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T21:26:39.219137Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T21:26:39.219143Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T21:26:39.233780Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T21:26:39.234321Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T21:26:40.962559Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-15T21:26:40.965073Z INFO property_map_server::routes::features: GET /api/features
+2026-03-15T21:26:41.229268Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=5 rows=14962301 parallel=true cells_before_filter=687 cells_after_filter=687 truncated=false bounds=46.0000,-12.0000,56.5000,12.0000 filters=0 filters_raw="-" travel_entries=0 agg_ms=204.4 total_ms=226.3
+2026-03-15T21:27:31.658405Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:28:08.681718Z INFO property_map_server: Prometheus metrics initialized
+2026-03-15T21:28:08.681878Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-15T21:28:08.681883Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-15T21:28:08.749237Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-15T21:28:08.749247Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-15T21:28:11.033956Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-15T21:28:11.033967Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-15T21:28:11.350626Z INFO property_map_server::data::property: buy listings joined rows=474965
+2026-03-15T21:28:11.352361Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-15T21:28:11.408617Z INFO property_map_server::data::property: rent listings joined rows=24345
+2026-03-15T21:28:11.408629Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-15T21:28:34.809978Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
+2026-03-15T21:28:34.810067Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
+2026-03-15T21:28:36.212393Z INFO property_map_server::data::property: Combined data selected rows=15702691
+2026-03-15T21:28:36.357174Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-15T21:28:36.708197Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-15T21:28:37.750679Z INFO property_map_server::data::property: Extracting string columns
+2026-03-15T21:28:39.971444Z INFO property_map_server::data::property: Building enum features
+2026-03-15T21:28:41.375658Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-15T21:28:43.534332Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-15T21:28:43.534341Z INFO property_map_server::data::property: Extracting listing features
+2026-03-15T21:28:44.063198Z INFO property_map_server::data::property: Listing features extracted properties_with_features=412749
+2026-03-15T21:28:44.063207Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-15T21:28:44.984019Z INFO property_map_server::data::property: Building interned strings
+2026-03-15T21:28:50.851336Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-15T21:28:53.262932Z INFO property_map_server::data::property: Data loading complete
+2026-03-15T21:28:54.905369Z INFO property_map_server: Property data loaded rows=15702691 features=68 enums=13
+2026-03-15T21:28:54.905380Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-15T21:28:55.002205Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-15T21:28:55.002215Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-15T21:28:55.397111Z INFO property_map_server::data::property: H3 precomputation complete (15702691 cells)
+2026-03-15T21:28:55.397137Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-15T21:28:55.397153Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-15T21:28:55.419145Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-15T21:28:55.531144Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-15T21:28:55.531706Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-15T21:28:55.567985Z INFO property_map_server: POI data loaded pois=678242
+2026-03-15T21:28:55.567992Z INFO property_map_server: Building POI spatial grid index
+2026-03-15T21:28:55.573226Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-15T21:28:55.573233Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-15T21:28:55.573743Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-15T21:28:55.574459Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-15T21:28:55.574532Z INFO property_map_server: Place data loaded places=3474
+2026-03-15T21:28:55.574542Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-15T21:28:55.574549Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-15T21:28:55.578317Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-15T21:29:03.183680Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-15T21:29:03.405251Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-15T21:29:03.405268Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-15T21:29:03.434100Z INFO property_map_server: PMTiles loaded successfully
+2026-03-15T21:29:03.477701Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-15T21:29:03.554483Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-15T21:29:03.554881Z INFO property_map_server: Precomputed features response groups=8
+2026-03-15T21:29:03.554897Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-15T21:29:03.617256Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-15T21:29:03.619944Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-15T21:29:03.623749Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-15T21:29:03.675548Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
+2026-03-15T21:29:03.684151Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-15T21:29:03.684193Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-15T21:29:03.684209Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-15T21:29:03.710851Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-15T21:29:03.711686Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-15T21:29:03.712508Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-15T21:29:03.719832Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1849
+2026-03-15T21:29:03.719857Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-15T21:29:03.719915Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-15T21:29:04.283936Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:29:06.207339Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-15T21:29:06.207379Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-15T21:30:03.765875Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:31:03.766908Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:32:03.766043Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:33:03.766227Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:34:03.765838Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:35:03.767373Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:36:03.766378Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:37:03.767873Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:38:03.766260Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:39:03.766874Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:40:03.766081Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:41:03.766181Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:42:03.764629Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:43:03.766341Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:44:03.765359Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:45:03.765298Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:46:03.766437Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:47:03.765492Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:48:03.765087Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:49:03.770788Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:50:03.777608Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:51:03.766162Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:52:03.765925Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:53:03.765007Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:54:03.765681Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:55:03.764567Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:56:03.770996Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:57:03.780547Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:58:03.765696Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T21:59:03.766040Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:00:03.768073Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:01:03.766610Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:02:03.766381Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:03:03.772124Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:04:03.765925Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:05:03.766612Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:06:03.766443Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:07:03.765680Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:08:03.766107Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:09:03.771436Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:10:03.766431Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:11:03.765836Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:12:03.765036Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:13:03.765945Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:14:03.766561Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:15:03.766491Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:16:03.765286Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:17:03.766117Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:18:03.765055Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:19:03.765059Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:20:03.763936Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:21:03.776040Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:22:03.764699Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:23:03.766017Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-15T22:24:03.765408Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
diff --git a/server-rs/logs/server.log.2026-03-17 b/server-rs/logs/server.log.2026-03-17
new file mode 100644
index 0000000..94ea237
--- /dev/null
+++ b/server-rs/logs/server.log.2026-03-17
@@ -0,0 +1,335 @@
+2026-03-17T07:30:51.418735Z INFO property_map_server: Prometheus metrics initialized
+2026-03-17T07:30:51.418950Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-17T07:30:51.418957Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-17T07:30:51.591217Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-17T07:30:51.591228Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-17T07:31:03.482386Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-17T07:31:03.482398Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-17T07:31:06.206982Z INFO property_map_server::data::property: buy listings joined rows=457076
+2026-03-17T07:31:06.207003Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-17T07:31:08.031097Z INFO property_map_server::data::property: rent listings joined rows=122594
+2026-03-17T07:31:08.031106Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-17T07:32:00.170695Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
+2026-03-17T07:32:00.170797Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
+2026-03-17T07:32:01.527808Z INFO property_map_server::data::property: Combined data selected rows=15783051
+2026-03-17T07:32:01.738022Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-17T07:32:02.164093Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-17T07:32:03.346133Z INFO property_map_server::data::property: Extracting string columns
+2026-03-17T07:32:05.803712Z INFO property_map_server::data::property: Building enum features
+2026-03-17T07:32:07.359340Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-17T07:32:09.567602Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-17T07:32:09.567612Z INFO property_map_server::data::property: Extracting listing features
+2026-03-17T07:32:10.194293Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
+2026-03-17T07:32:10.194304Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-17T07:32:11.130691Z INFO property_map_server::data::property: Building interned strings
+2026-03-17T07:32:17.391642Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-17T07:32:20.030170Z INFO property_map_server::data::property: Data loading complete
+2026-03-17T07:32:21.686179Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
+2026-03-17T07:32:21.686189Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-17T07:32:22.119885Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-17T07:32:22.119896Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-17T07:32:22.577256Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
+2026-03-17T07:32:22.577783Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-17T07:32:22.577790Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-17T07:32:22.606628Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-17T07:32:22.723396Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-17T07:32:22.724011Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-17T07:32:22.763121Z INFO property_map_server: POI data loaded pois=678242
+2026-03-17T07:32:22.763130Z INFO property_map_server: Building POI spatial grid index
+2026-03-17T07:32:22.768959Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-17T07:32:22.768968Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-17T07:32:22.772858Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-17T07:32:22.773855Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-17T07:32:22.774015Z INFO property_map_server: Place data loaded places=3474
+2026-03-17T07:32:22.774027Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-17T07:32:22.774032Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-17T07:32:22.787541Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-17T07:32:31.937299Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-17T07:32:32.173875Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-17T07:32:32.174039Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-17T07:32:32.271059Z INFO property_map_server: PMTiles loaded successfully
+2026-03-17T07:32:32.315679Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-17T07:32:32.394604Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-17T07:32:32.394776Z INFO property_map_server: Precomputed features response groups=8
+2026-03-17T07:32:32.394795Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-17T07:32:32.593635Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-17T07:32:32.598562Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-17T07:32:32.602615Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-17T07:32:32.700044Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
+2026-03-17T07:32:32.703401Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-17T07:32:32.703422Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-17T07:32:32.703435Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-17T07:32:33.124089Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-17T07:32:33.129130Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-17T07:32:33.136319Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-17T07:32:33.199470Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
+2026-03-17T07:32:33.199512Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-17T07:32:33.199568Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-17T07:32:33.247029Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:32:41.343709Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-17T07:32:41.343741Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-17T07:33:33.247983Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:34:33.248115Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:35:33.247077Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:36:33.246775Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:37:33.245462Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:38:33.245965Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:39:33.245978Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:40:33.246783Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:41:33.245498Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:42:33.245587Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:43:33.245907Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:44:33.246696Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:45:33.246006Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T07:46:30.259530Z INFO property_map_server: Prometheus metrics initialized
+2026-03-17T07:46:30.259726Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-17T07:46:30.259735Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-17T07:46:30.325086Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-17T07:46:30.325097Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-17T07:46:32.757459Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-17T07:46:32.757469Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-17T07:46:33.043727Z INFO property_map_server::data::property: buy listings joined rows=457076
+2026-03-17T07:46:33.043750Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-17T07:46:33.139537Z INFO property_map_server::data::property: rent listings joined rows=122594
+2026-03-17T07:46:33.139545Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-17T08:31:50.056528Z INFO property_map_server: Prometheus metrics initialized
+2026-03-17T08:31:50.056716Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-17T08:31:50.056723Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-17T08:31:50.259958Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-17T08:31:50.259971Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-17T08:32:02.569149Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-17T08:32:02.569201Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-17T08:32:03.699632Z INFO property_map_server::data::property: buy listings joined rows=457076
+2026-03-17T08:32:03.699651Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-17T08:32:03.826074Z INFO property_map_server::data::property: rent listings joined rows=122594
+2026-03-17T08:32:03.826084Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-17T08:32:43.785403Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
+2026-03-17T08:32:43.785499Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
+2026-03-17T08:32:45.220814Z INFO property_map_server::data::property: Combined data selected rows=15783051
+2026-03-17T08:32:45.421342Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-17T08:32:45.834125Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-17T08:32:47.061266Z INFO property_map_server::data::property: Extracting string columns
+2026-03-17T08:32:49.344991Z INFO property_map_server::data::property: Building enum features
+2026-03-17T08:32:50.754854Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-17T08:32:52.906620Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-17T08:32:52.906629Z INFO property_map_server::data::property: Extracting listing features
+2026-03-17T08:32:53.563050Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
+2026-03-17T08:32:53.563059Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-17T08:32:54.502830Z INFO property_map_server::data::property: Building interned strings
+2026-03-17T08:33:00.593312Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-17T08:33:03.178312Z INFO property_map_server::data::property: Data loading complete
+2026-03-17T08:33:04.964374Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
+2026-03-17T08:33:04.964383Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-17T08:33:05.065094Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-17T08:33:05.065102Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-17T08:33:05.486703Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
+2026-03-17T08:33:05.486729Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-17T08:33:05.486734Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-17T08:33:05.529351Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-17T08:33:05.642021Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-17T08:33:05.642611Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-17T08:33:05.681563Z INFO property_map_server: POI data loaded pois=678242
+2026-03-17T08:33:05.681574Z INFO property_map_server: Building POI spatial grid index
+2026-03-17T08:33:05.687162Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-17T08:33:05.687169Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-17T08:33:05.705798Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-17T08:33:05.706609Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-17T08:33:05.706675Z INFO property_map_server: Place data loaded places=3474
+2026-03-17T08:33:05.706689Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-17T08:33:05.706695Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-17T08:33:05.780250Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-17T08:33:14.655514Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-17T08:33:14.888462Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-17T08:33:14.888478Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-17T08:33:15.021983Z INFO property_map_server: PMTiles loaded successfully
+2026-03-17T08:33:15.065572Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-17T08:33:15.140720Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-17T08:33:15.141331Z INFO property_map_server: Precomputed features response groups=8
+2026-03-17T08:33:15.141349Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-17T08:33:15.246791Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-17T08:33:15.254863Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-17T08:33:15.258892Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-17T08:33:15.329192Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
+2026-03-17T08:33:15.333036Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-17T08:33:15.333055Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-17T08:33:15.333066Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-17T08:33:15.398969Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-17T08:33:15.403743Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-17T08:33:15.404640Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-17T08:33:15.414586Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
+2026-03-17T08:33:15.414612Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-17T08:33:15.414666Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-17T08:33:16.003045Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:33:19.581012Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-17T08:33:19.581049Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-17T08:33:22.213990Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-17T08:33:22.216578Z INFO property_map_server::routes::features: GET /api/features
+2026-03-17T08:33:22.227193Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-17T08:33:22.232847Z INFO property_map_server::routes::features: GET /api/features
+2026-03-17T08:33:22.409378Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=145554 parallel=true cells_before_filter=455 cells_after_filter=297 truncated=false bounds=51.4896,-0.1648,51.5404,-0.0952 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.4 agg_ms=7.5 json_ms=0.8 total_ms=8.7
+2026-03-17T08:33:22.446379Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=145554 parallel=true cells_before_filter=455 cells_after_filter=297 truncated=false bounds=51.4896,-0.1648,51.5404,-0.0952 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=4.1 json_ms=0.5 total_ms=4.7
+2026-03-17T08:34:15.461433Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:34:29.710796Z INFO property_map_server::routes::features: GET /api/features
+2026-03-17T08:34:29.713513Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
+2026-03-17T08:34:30.274542Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=145554 parallel=true cells_before_filter=455 cells_after_filter=297 truncated=false bounds=51.4896,-0.1648,51.5404,-0.0952 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=2.5 json_ms=0.6 total_ms=3.2
+2026-03-17T08:34:31.462250Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4987ffff resolution=9 total_count=243 filters=1 filters_raw="Listing status:Historical sale" ms=0.2
+2026-03-17T08:34:31.674788Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=82474 parallel=true cells_before_filter=296 cells_after_filter=201 truncated=false bounds=51.4896,-0.1524,51.5404,-0.1076 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=0.9 json_ms=0.5 total_ms=1.5
+2026-03-17T08:34:32.542179Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4d33ffff resolution=9 total_count=746 filters=1 filters_raw="Listing status:Historical sale" ms=0.5
+2026-03-17T08:34:34.469487Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4d33ffff resolution=9 total_count=6 filters=1 filters_raw="Listing status:For rent" ms=0.1
+2026-03-17T08:34:34.620706Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=82474 parallel=true cells_before_filter=274 cells_after_filter=196 truncated=false bounds=51.4896,-0.1524,51.5404,-0.1076 filters=1 filters_raw="Listing status:For rent" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=0.7 json_ms=0.4 total_ms=1.1
+2026-03-17T08:35:15.464691Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:36:15.461317Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:37:15.462465Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:38:15.461428Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:39:15.463264Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:40:15.466916Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:41:15.463402Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:42:15.462539Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:43:15.461880Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:44:15.462263Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:45:15.461882Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:46:15.462228Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:47:15.462476Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:47:28.935265Z INFO property_map_server: Prometheus metrics initialized
+2026-03-17T08:47:28.935449Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-17T08:47:28.935457Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-17T08:47:29.007775Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-17T08:47:29.007785Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-17T08:47:31.674791Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-17T08:47:31.674802Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-17T08:47:31.972527Z INFO property_map_server::data::property: buy listings joined rows=457076
+2026-03-17T08:47:31.972545Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-17T08:47:32.082470Z INFO property_map_server::data::property: rent listings joined rows=122594
+2026-03-17T08:47:32.082480Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-17T08:47:43.806418Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
+2026-03-17T08:47:43.806509Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
+2026-03-17T08:47:45.135285Z INFO property_map_server::data::property: Combined data selected rows=15783051
+2026-03-17T08:47:45.326377Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-17T08:47:45.712528Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-17T08:47:46.876195Z INFO property_map_server::data::property: Extracting string columns
+2026-03-17T08:47:49.145516Z INFO property_map_server::data::property: Building enum features
+2026-03-17T08:47:50.661409Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-17T08:47:52.947453Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-17T08:47:52.947462Z INFO property_map_server::data::property: Extracting listing features
+2026-03-17T08:47:53.599162Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
+2026-03-17T08:47:53.599171Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-17T08:47:54.619942Z INFO property_map_server::data::property: Building interned strings
+2026-03-17T08:48:00.802774Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-17T08:48:03.547995Z INFO property_map_server::data::property: Data loading complete
+2026-03-17T08:48:05.049275Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
+2026-03-17T08:48:05.049293Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-17T08:48:05.459943Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-17T08:48:05.459953Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-17T08:48:05.865563Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
+2026-03-17T08:48:05.865637Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-17T08:48:05.865651Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-17T08:48:05.886166Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-17T08:48:06.006159Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-17T08:48:06.006744Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-17T08:48:06.043360Z INFO property_map_server: POI data loaded pois=678242
+2026-03-17T08:48:06.043368Z INFO property_map_server: Building POI spatial grid index
+2026-03-17T08:48:06.048757Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-17T08:48:06.048766Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-17T08:48:06.049291Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-17T08:48:06.050002Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-17T08:48:06.050053Z INFO property_map_server: Place data loaded places=3474
+2026-03-17T08:48:06.050061Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-17T08:48:06.050064Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-17T08:48:06.062151Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-17T08:48:15.297171Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-17T08:48:15.545357Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-17T08:48:15.545379Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-17T08:48:15.640450Z INFO property_map_server: PMTiles loaded successfully
+2026-03-17T08:48:15.684715Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-17T08:48:15.789766Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-17T08:48:15.790261Z INFO property_map_server: Precomputed features response groups=8
+2026-03-17T08:48:15.790275Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-17T08:48:15.852396Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-17T08:48:15.854872Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-17T08:48:15.858800Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-17T08:48:15.911308Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
+2026-03-17T08:48:15.915275Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-17T08:48:15.915303Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-17T08:48:15.915316Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-17T08:48:16.153964Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-17T08:48:16.155556Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-17T08:48:16.156564Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-17T08:48:16.168132Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
+2026-03-17T08:48:16.168166Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-17T08:48:16.168228Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-17T08:48:16.774064Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:48:18.295547Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-17T08:48:18.295586Z INFO property_map_server: Server listening on 0.0.0.0:8001
+2026-03-17T08:49:16.216499Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:50:16.215664Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:51:16.214094Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:52:16.215038Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
+2026-03-17T08:53:00.492875Z INFO property_map_server: Prometheus metrics initialized
+2026-03-17T08:53:00.493149Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
+2026-03-17T08:53:00.493156Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
+2026-03-17T08:53:00.728565Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
+2026-03-17T08:53:00.728575Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
+2026-03-17T08:53:03.595748Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
+2026-03-17T08:53:03.595759Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
+2026-03-17T08:53:03.975669Z INFO property_map_server::data::property: buy listings joined rows=457076
+2026-03-17T08:53:03.975687Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
+2026-03-17T08:53:04.083853Z INFO property_map_server::data::property: rent listings joined rows=122594
+2026-03-17T08:53:04.083863Z INFO property_map_server::data::property: Concatenating all data sources
+2026-03-17T08:53:19.531799Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
+2026-03-17T08:53:19.531893Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
+2026-03-17T08:53:20.977401Z INFO property_map_server::data::property: Combined data selected rows=15783051
+2026-03-17T08:53:21.166389Z INFO property_map_server::data::property: Extracting numeric feature columns
+2026-03-17T08:53:21.555895Z INFO property_map_server::data::property: Computing histograms for numeric features
+2026-03-17T08:53:22.777545Z INFO property_map_server::data::property: Extracting string columns
+2026-03-17T08:53:25.067611Z INFO property_map_server::data::property: Building enum features
+2026-03-17T08:53:26.433346Z INFO property_map_server::data::property: Extracting renovation history
+2026-03-17T08:53:28.667594Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
+2026-03-17T08:53:28.667602Z INFO property_map_server::data::property: Extracting listing features
+2026-03-17T08:53:29.309247Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
+2026-03-17T08:53:29.309255Z INFO property_map_server::data::property: Sorting rows by spatial locality
+2026-03-17T08:53:30.205482Z INFO property_map_server::data::property: Building interned strings
+2026-03-17T08:53:36.247881Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
+2026-03-17T08:53:38.758705Z INFO property_map_server::data::property: Data loading complete
+2026-03-17T08:53:40.180446Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
+2026-03-17T08:53:40.180455Z INFO property_map_server: Building spatial grid index (0.01° cells)
+2026-03-17T08:53:40.577820Z INFO property_map_server: Precomputing H3 cells at resolution 12
+2026-03-17T08:53:40.577828Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
+2026-03-17T08:53:40.972135Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
+2026-03-17T08:53:40.972155Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
+2026-03-17T08:53:40.972161Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
+2026-03-17T08:53:41.018292Z INFO property_map_server::data::poi: Loaded 678242 POIs
+2026-03-17T08:53:41.129204Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
+2026-03-17T08:53:41.129769Z INFO property_map_server::data::poi: POI data loading complete.
+2026-03-17T08:53:41.168005Z INFO property_map_server: POI data loaded pois=678242
+2026-03-17T08:53:41.168011Z INFO property_map_server: Building POI spatial grid index
+2026-03-17T08:53:41.173291Z INFO property_map_server: Loading place data from /app/data/places.parquet
+2026-03-17T08:53:41.173297Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
+2026-03-17T08:53:41.175229Z INFO property_map_server::data::places: Loaded 3474 places
+2026-03-17T08:53:41.176075Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
+2026-03-17T08:53:41.176126Z INFO property_map_server: Place data loaded places=3474
+2026-03-17T08:53:41.176134Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
+2026-03-17T08:53:41.176137Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
+2026-03-17T08:53:41.178186Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
+2026-03-17T08:53:51.542107Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
+2026-03-17T08:53:51.769077Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
+2026-03-17T08:53:51.769098Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
+2026-03-17T08:53:51.769313Z INFO property_map_server: PMTiles loaded successfully
+2026-03-17T08:53:51.811454Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
+2026-03-17T08:53:51.881249Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
+2026-03-17T08:53:51.881405Z INFO property_map_server: Precomputed features response groups=8
+2026-03-17T08:53:51.881422Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
+2026-03-17T08:53:51.933372Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
+2026-03-17T08:53:51.935544Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
+2026-03-17T08:53:51.938605Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
+2026-03-17T08:53:51.988188Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
+2026-03-17T08:53:51.992737Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
+2026-03-17T08:53:51.992761Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
+2026-03-17T08:53:51.992778Z INFO property_map_server: Loading travel time data from /app/data/travel-times
+2026-03-17T08:53:52.012596Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
+2026-03-17T08:53:52.012912Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
+2026-03-17T08:53:52.013296Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
+2026-03-17T08:53:52.015215Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
+2026-03-17T08:53:52.015233Z INFO property_map_server: Travel time store loaded modes=4
+2026-03-17T08:53:52.015276Z INFO property_map_server: Precomputed AI filters system prompt
+2026-03-17T08:53:54.777281Z INFO property_map_server: All memory pages locked (mlockall)
+2026-03-17T08:53:54.777322Z INFO property_map_server: Server listening on 0.0.0.0:8001
diff --git a/server-rs/src/auth.rs b/server-rs/src/auth.rs
index e513f44..9ac6be5 100644
--- a/server-rs/src/auth.rs
+++ b/server-rs/src/auth.rs
@@ -18,8 +18,6 @@ pub struct PocketBaseUser {
pub id: String,
pub email: String,
#[serde(default)]
- pub verified: bool,
- #[serde(default)]
pub is_admin: bool,
#[serde(default)]
pub subscription: String,
@@ -57,9 +55,13 @@ impl TokenCache {
// Evict expired entries first
let now = Instant::now();
map.retain(|_, (_, created)| now.duration_since(*created).as_secs() < TOKEN_TTL_SECS);
- // If still too many, clear all
+ // If still too many, evict oldest half instead of clearing all
+ // (avoids thundering herd where every request re-validates at once)
if map.len() >= MAX_CACHE_ENTRIES {
- map.clear();
+ let mut ages: Vec = map.values().map(|(_, created)| *created).collect();
+ ages.sort();
+ let median = ages[ages.len() / 2];
+ map.retain(|_, (_, created)| *created >= median);
}
}
map.insert(token, (user, Instant::now()));
diff --git a/server-rs/src/consts.rs b/server-rs/src/consts.rs
index c5c0b1d..c2976be 100644
--- a/server-rs/src/consts.rs
+++ b/server-rs/src/consts.rs
@@ -28,8 +28,3 @@ pub const SERVICE_CALL_TIMEOUT: u64 = 120;
/// Inner London free zone bounds (south, west, north, east) — roughly zone 1.
/// Users without a license can only query data within these bounds.
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.44, -0.31, 51.59, 0.05);
-
-/// Exact demo bounds (south, west, north, east) sent by the homepage ScrollStory.
-/// Requests matching these exact values bypass the license check so the
-/// animation works for anonymous visitors. Only this specific viewport is allowed.
-pub const DEMO_BOUNDS: (f64, f64, f64, f64) = (46.0, -12.0, 56.5, 12.0);
diff --git a/server-rs/src/data/poi.rs b/server-rs/src/data/poi.rs
index c909682..8488cfb 100644
--- a/server-rs/src/data/poi.rs
+++ b/server-rs/src/data/poi.rs
@@ -23,7 +23,7 @@ pub struct POIData {
/// Byte offset into `id_buffer` where each row's ID starts.
id_offsets: Vec,
/// Length in bytes of each row's ID.
- id_lengths: Vec,
+ id_lengths: Vec,
pub group: InternedColumn,
pub category: InternedColumn,
pub name: Vec,
@@ -101,7 +101,7 @@ impl POIData {
let mut id_lengths = Vec::with_capacity(row_count);
for s in &id_raw {
let offset = id_buffer.len() as u32;
- let length = s.len().min(u8::MAX as usize) as u8;
+ let length = s.len().min(u16::MAX as usize) as u16;
id_offsets.push(offset);
id_lengths.push(length);
id_buffer.push_str(&s[..length as usize]);
diff --git a/server-rs/src/data/postcodes.rs b/server-rs/src/data/postcodes.rs
index 491cca9..ba0a14e 100644
--- a/server-rs/src/data/postcodes.rs
+++ b/server-rs/src/data/postcodes.rs
@@ -128,6 +128,7 @@ impl PostcodeData {
// Compute centroid across all vertices from all rings
let total_vertices: usize = rings.iter().map(|ring| ring.len()).sum();
let centroid = if total_vertices == 0 {
+ tracing::warn!(postcode = %postcode, "Postcode polygon has zero vertices, defaulting centroid to (0,0)");
(0.0, 0.0)
} else {
let mut sum_lat: f32 = 0.0;
@@ -168,7 +169,12 @@ impl PostcodeData {
local_aabbs.push((aabb_south, aabb_west, aabb_north, aabb_east));
}
- Ok::<_, anyhow::Error>((local_postcodes, local_polygons, local_centroids, local_aabbs))
+ Ok::<_, anyhow::Error>((
+ local_postcodes,
+ local_polygons,
+ local_centroids,
+ local_aabbs,
+ ))
})
.collect::, _>>()?;
diff --git a/server-rs/src/data/property.rs b/server-rs/src/data/property.rs
index a93293e..a95d4e5 100644
--- a/server-rs/src/data/property.rs
+++ b/server-rs/src/data/property.rs
@@ -538,6 +538,20 @@ impl PropertyData {
Ok(joined)
};
let listings_buy = load_listings(listings_buy_path, "buy")?;
+ // Derive "Asking price per sqm" if not already present
+ let listings_buy = if listings_buy.schema().get("Asking price per sqm").is_none() {
+ listings_buy
+ .lazy()
+ .with_column(
+ (col("Asking price").cast(DataType::Float64) / col("Total floor area (sqm)"))
+ .round(0)
+ .alias("Asking price per sqm"),
+ )
+ .collect()
+ .context("Failed to derive Asking price per sqm")?
+ } else {
+ listings_buy
+ };
let listings_rent = load_listings(listings_rent_path, "rent")?;
// Concatenate all rows into a single DataFrame
diff --git a/server-rs/src/data/travel_time.rs b/server-rs/src/data/travel_time.rs
index a3a2ae7..0fc6d5f 100644
--- a/server-rs/src/data/travel_time.rs
+++ b/server-rs/src/data/travel_time.rs
@@ -236,19 +236,24 @@ impl TravelTimeStore {
}
}
-/// Slugify a place name to match travel time file naming convention.
-/// "Abbey Hey" → "abbey-hey", "A'Bhuaile Ghlas" → "a-bhuaile-ghlas"
+/// Slugify a place name to match Java `originFilename()` convention.
+/// Strips non-alphanumeric chars (except spaces/hyphens) first, then collapses
+/// whitespace to hyphens. This matches Java's `replaceAll("[^a-z0-9 -]", "")`
+/// followed by `replaceAll("\\s+", "-")`.
+/// "King's Cross" → "kings-cross", "Abbey Hey" → "abbey-hey"
pub fn slugify(name: &str) -> String {
let mut result = String::with_capacity(name.len());
let mut last_was_hyphen = true; // Start true to skip leading hyphens
for ch in name.chars() {
+ let lower = ch.to_ascii_lowercase();
if ch.is_ascii_alphanumeric() {
- result.push(ch.to_ascii_lowercase());
+ result.push(lower);
last_was_hyphen = false;
- } else if !last_was_hyphen {
+ } else if (ch == ' ' || ch == '-') && !last_was_hyphen {
result.push('-');
last_was_hyphen = true;
}
+ // Other non-alphanumeric chars (apostrophes, ampersands, etc.) are stripped
}
if result.ends_with('-') {
result.pop();
@@ -266,6 +271,32 @@ mod tests {
assert_eq!(slugify("London"), "london");
}
+ #[test]
+ fn slugify_apostrophes_stripped() {
+ assert_eq!(slugify("King's Cross"), "kings-cross");
+ assert_eq!(
+ slugify("Earl's Court tube station"),
+ "earls-court-tube-station"
+ );
+ assert_eq!(slugify("St. Paul's tube station"), "st-pauls-tube-station");
+ assert_eq!(
+ slugify("Regent's Park tube station"),
+ "regents-park-tube-station"
+ );
+ }
+
+ #[test]
+ fn slugify_special_chars_stripped() {
+ assert_eq!(
+ slugify("Cobham & Stoke d'Abernon railway station"),
+ "cobham-stoke-dabernon-railway-station"
+ );
+ assert_eq!(
+ slugify("Ravenglass (R&ER) railway station"),
+ "ravenglass-rer-railway-station"
+ );
+ }
+
#[test]
fn strip_numeric_prefix_basic() {
assert_eq!(
diff --git a/server-rs/src/features.rs b/server-rs/src/features.rs
index cc906fd..f51c456 100644
--- a/server-rs/src/features.rs
+++ b/server-rs/src/features.rs
@@ -68,9 +68,9 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
features: &[
FeatureConfig {
name: "Last known price",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 2_000_000.0,
+ bounds: Bounds::Percentile {
+ low: 0.0,
+ high: 98.0,
},
step: 10000.0,
description: "Most recent sale price from the Land Registry",
@@ -79,15 +79,15 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
prefix: "£",
suffix: "",
raw: false,
- absolute: true,
+ absolute: false,
modes: &["historical"],
linked: "",
},
FeatureConfig {
name: "Estimated current price",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 2_000_000.0,
+ bounds: Bounds::Percentile {
+ low: 0.0,
+ high: 98.0,
},
step: 10000.0,
description: "Inflation-adjusted estimate of the current property value",
@@ -96,7 +96,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
prefix: "£",
suffix: "",
raw: false,
- absolute: true,
+ absolute: false,
modes: &["historical"],
linked: "Asking price",
},
@@ -252,9 +252,9 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
FeatureConfig {
name: "Asking price",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 2_000_000.0,
+ bounds: Bounds::Percentile {
+ low: 0.0,
+ high: 98.0,
},
step: 10000.0,
description: "Listed asking price for properties currently for sale",
@@ -263,15 +263,15 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
prefix: "£",
suffix: "",
raw: false,
- absolute: true,
+ absolute: false,
modes: &["buy"],
linked: "Estimated current price",
},
FeatureConfig {
name: "Asking rent (monthly)",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 10_000.0,
+ bounds: Bounds::Percentile {
+ low: 0.0,
+ high: 98.0,
},
step: 50.0,
description: "Listed monthly rent for properties currently for rent",
@@ -280,7 +280,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
prefix: "£",
suffix: "/mo",
raw: false,
- absolute: true,
+ absolute: false,
modes: &["rent"],
linked: "Estimated monthly rent",
},
@@ -870,7 +870,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
min: 0.0,
max: 100.0,
},
- step: 1.0,
+ step: 0.1,
description: "Percentage of population identifying as South Asian",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Indian, Pakistani, Bangladeshi, or any other Asian background.",
source: "ethnicity",
@@ -887,7 +887,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
min: 0.0,
max: 100.0,
},
- step: 1.0,
+ step: 0.1,
description: "Percentage of population identifying as East Asian",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Chinese.",
source: "ethnicity",
@@ -904,7 +904,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
min: 0.0,
max: 100.0,
},
- step: 1.0,
+ step: 0.1,
description: "Percentage of population identifying as Black",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Black, Black British, Caribbean, or African.",
source: "ethnicity",
@@ -921,7 +921,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
min: 0.0,
max: 100.0,
},
- step: 1.0,
+ step: 0.1,
description: "Percentage of population identifying as Mixed or Multiple ethnic groups",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Mixed or Multiple ethnic groups (White and Black Caribbean, White and Black African, White and Asian, or any other Mixed or Multiple background).",
source: "ethnicity",
@@ -938,7 +938,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
min: 0.0,
max: 100.0,
},
- step: 1.0,
+ step: 0.1,
description: "Percentage of population identifying as Other ethnic group",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Other ethnic group (Arab or any other ethnic group not covered by the main categories).",
source: "ethnicity",
diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs
index f6bda66..b0aff6b 100644
--- a/server-rs/src/main.rs
+++ b/server-rs/src/main.rs
@@ -35,7 +35,7 @@ use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
-use state::AppState;
+use state::{AppState, SharedState};
#[derive(Parser)]
#[command(
@@ -365,20 +365,21 @@ async fn main() -> anyhow::Result<()> {
info!("Precomputed AI filters system prompt");
let token_cache = Arc::new(auth::TokenCache::new());
+ let superuser_token_cache = Arc::new(pocketbase::SuperuserTokenCache::new());
- let state = Arc::new(AppState {
+ let app_state = AppState {
data: property_data,
grid,
h3_cells,
- poi_data,
- poi_grid,
- place_data,
- postcode_data,
+ poi_data: Arc::new(poi_data),
+ poi_grid: Arc::new(poi_grid),
+ place_data: Arc::new(place_data),
+ postcode_data: Arc::new(postcode_data),
feature_name_to_index,
min_keys,
max_keys,
avg_keys,
- poi_category_groups,
+ poi_category_groups: Arc::new(poi_category_groups),
features_response,
screenshot_url: cli.screenshot_url,
public_url: cli.public_url,
@@ -392,19 +393,29 @@ async fn main() -> anyhow::Result<()> {
gemini_model: cli.gemini_model,
travel_time_store,
token_cache,
+ superuser_token_cache,
ai_filters_system_prompt,
google_maps_api_key: cli.google_maps_api_key,
stripe_secret_key: cli.stripe_secret_key,
stripe_webhook_secret: cli.stripe_webhook_secret,
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
- });
+ };
+
+ let shared = Arc::new(SharedState::new(
+ app_state,
+ cli.properties,
+ cli.postcode_features,
+ cli.listings_buy,
+ cli.listings_rent,
+ ));
// Start background PocketBase metrics poller (users, saved searches/properties counts)
- pocketbase::start_metrics_poller(state.clone());
+ pocketbase::start_metrics_poller(shared.clone());
+ let initial_state = shared.load_state();
let cors = CorsLayer::new()
.allow_origin(
- state
+ initial_state
.public_url
.parse::()
.expect("public_url must be a valid header value"),
@@ -413,184 +424,81 @@ async fn main() -> anyhow::Result<()> {
.allow_headers(AllowHeaders::mirror_request())
.allow_credentials(true);
- let state_features = state.clone();
- let state_hexagons = state.clone();
- let state_postcodes = state.clone();
- let state_postcode_lookup = state.clone();
- let state_pois = state.clone();
- let state_poi_categories = state.clone();
- let state_hexagon_properties = state.clone();
- let state_hexagon_stats = state.clone();
- let state_screenshot = state.clone();
- let state_export = state.clone();
- let state_crawler = state.clone();
- let state_pb = state.clone();
- let state_postcode_stats = state.clone();
- let state_postcode_properties = state.clone();
- let state_places = state.clone();
- let state_shorten = state.clone();
- let state_short_url = state.clone();
- let state_ai_filters = state.clone();
- let state_streetview = state.clone();
- let state_newsletter = state.clone();
- let state_travel_modes = state.clone();
- let state_travel_destinations = state.clone();
- let state_checkout = state.clone();
- let state_stripe_webhook = state.clone();
- let state_pricing = state.clone();
- let state_invites_list = state.clone();
- let state_invites_create = state.clone();
- let state_invite_get = state.clone();
- let state_redeem_invite = state.clone();
- let state_journey = state.clone();
- let state_telemetry = state.clone();
+ // Handlers use Axum's State extractor to get Arc, then call
+ // load_state() to get the current Arc. This enables hot-reload:
+ // the reload endpoint swaps in a new AppState, and subsequent requests pick it up.
+ let s_crawler = shared.clone();
+
+ let reader_tile = tile_reader.clone();
+ let reader_style = tile_reader.clone();
+ let public_url_tiles = initial_state.public_url.clone();
let api = Router::new()
- .route(
- "/api/features",
- get(move || routes::get_features(state_features.clone())),
- )
+ .route("/api/features", get(routes::get_features))
.route(
"/api/hexagons",
- get(move |ext, query| routes::get_hexagons(state_hexagons.clone(), ext, query)),
+ get(routes::get_hexagons).layer(ConcurrencyLimitLayer::new(20)),
)
.route(
"/api/postcodes",
- get(move |ext, query| routes::get_postcodes(state_postcodes.clone(), ext, query)),
- )
- .route(
- "/api/postcode/{postcode}",
- get(move |path| routes::get_postcode_lookup(state_postcode_lookup.clone(), path)),
+ get(routes::get_postcodes).layer(ConcurrencyLimitLayer::new(20)),
)
+ .route("/api/postcode/{postcode}", get(routes::get_postcode_lookup))
.route(
"/api/pois",
- get(move |query| routes::get_pois(state_pois.clone(), query)),
- )
- .route(
- "/api/poi-categories",
- get(move || routes::get_poi_categories(state_poi_categories.clone())),
- )
- .route(
- "/api/places",
- get(move |query| routes::get_places(state_places.clone(), query)),
- )
- .route(
- "/api/travel-modes",
- get(move || routes::get_travel_modes(state_travel_modes.clone())),
+ get(routes::get_pois).layer(ConcurrencyLimitLayer::new(20)),
)
+ .route("/api/poi-categories", get(routes::get_poi_categories))
+ .route("/api/places", get(routes::get_places))
+ .route("/api/travel-modes", get(routes::get_travel_modes))
.route(
"/api/travel-destinations",
- get(move |query| {
- routes::get_travel_destinations(state_travel_destinations.clone(), query)
- }),
- )
- .route(
- "/api/journey",
- get(move |query| routes::get_journey(state_journey.clone(), query)),
+ get(routes::get_travel_destinations),
)
+ .route("/api/journey", get(routes::get_journey))
.route(
"/api/hexagon-properties",
- get(move |ext, query| {
- routes::get_hexagon_properties(state_hexagon_properties.clone(), ext, query)
- }),
- )
- .route(
- "/api/hexagon-stats",
- get(move |ext, query| {
- routes::get_hexagon_stats(state_hexagon_stats.clone(), ext, query)
- }),
- )
- .route(
- "/api/postcode-stats",
- get(move |ext, query| {
- routes::get_postcode_stats(state_postcode_stats.clone(), ext, query)
- }),
+ get(routes::get_hexagon_properties),
)
+ .route("/api/hexagon-stats", get(routes::get_hexagon_stats))
+ .route("/api/postcode-stats", get(routes::get_postcode_stats))
.route(
"/api/postcode-properties",
- get(move |ext, query| {
- routes::get_postcode_properties(state_postcode_properties.clone(), ext, query)
- }),
- )
- .route(
- "/api/screenshot",
- get(move |headers, query| {
- routes::get_screenshot(state_screenshot.clone(), headers, query)
- }),
+ get(routes::get_postcode_properties),
)
+ .route("/api/screenshot", get(routes::get_screenshot))
.route(
"/api/export",
- get(move |headers, ext, query| {
- routes::get_export(state_export.clone(), headers, ext, query)
- })
- .layer(ConcurrencyLimitLayer::new(3)),
+ get(routes::get_export).layer(ConcurrencyLimitLayer::new(3)),
)
.route("/api/me", get(routes::get_me))
- .route(
- "/api/shorten",
- post(move |body| routes::post_shorten(state_shorten.clone(), body)),
- )
+ .route("/api/shorten", post(routes::post_shorten))
.route(
"/api/ai-filters",
- post(move |ext, body| routes::post_ai_filters(state_ai_filters.clone(), ext, body))
- .layer(ConcurrencyLimitLayer::new(5)),
- )
- .route(
- "/api/streetview",
- get(move |query| routes::get_streetview(state_streetview.clone(), query)),
- )
- .route(
- "/api/newsletter",
- patch(move |ext, body| routes::patch_newsletter(state_newsletter.clone(), ext, body)),
- )
- .route(
- "/api/pricing",
- get(move || routes::get_pricing(state_pricing.clone())),
+ post(routes::post_ai_filters).layer(ConcurrencyLimitLayer::new(5)),
)
+ .route("/api/streetview", get(routes::get_streetview))
+ .route("/api/newsletter", patch(routes::patch_newsletter))
+ .route("/api/pricing", get(routes::get_pricing))
.route(
"/api/checkout",
- post(move |ext, body| routes::post_checkout(state_checkout.clone(), ext, body))
- .layer(ConcurrencyLimitLayer::new(10)),
- )
- .route(
- "/api/stripe-webhook",
- post(move |headers, body| {
- routes::post_stripe_webhook(state_stripe_webhook.clone(), headers, body)
- }),
+ post(routes::post_checkout).layer(ConcurrencyLimitLayer::new(10)),
)
+ .route("/api/stripe-webhook", post(routes::post_stripe_webhook))
.route(
"/api/invites",
- get(move |ext| routes::get_invites(state_invites_list.clone(), ext)).post(
- move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body),
- ),
+ get(routes::get_invites).post(routes::post_invites),
)
+ .route("/api/invite/{code}", get(routes::get_invite))
+ .route("/api/redeem-invite", post(routes::post_redeem_invite))
+ .route("/s/{code}", get(routes::get_short_url))
+ .route("/api/telemetry", post(routes::post_telemetry))
+ .route("/api/reload", post(routes::post_reload))
.route(
- "/api/invite/{code}",
- get(move |ext, path| routes::get_invite(state_invite_get.clone(), ext, path)),
+ "/pb/{*rest}",
+ any(routes::proxy_to_pocketbase).layer(ConcurrencyLimitLayer::new(10)),
)
- .route(
- "/api/redeem-invite",
- post(move |ext, body| {
- routes::post_redeem_invite(state_redeem_invite.clone(), ext, body)
- }),
- )
- .route(
- "/s/{code}",
- get(move |path| routes::get_short_url(state_short_url.clone(), path)),
- )
- .route(
- "/api/telemetry",
- post(move |ext, headers, body| {
- let _ = state_telemetry.clone();
- routes::post_telemetry(ext, headers, body)
- }),
- );
-
- // Add tile routes
- let reader_tile = tile_reader.clone();
- let reader_style = tile_reader.clone();
- let public_url_tiles = state.public_url.clone();
- let api = api
+ // Tile routes use a different state type — kept as closures
.route(
"/api/tiles/{z}/{x}/{y}",
get(move |path| routes::get_tile(axum::extract::State(reader_tile.clone()), path)),
@@ -607,10 +515,7 @@ async fn main() -> anyhow::Result<()> {
"/metrics",
get(move || metrics::metrics_handler(metrics_handle.clone())),
)
- .route(
- "/pb/{*rest}",
- any(move |req| routes::proxy_to_pocketbase(state_pb.clone(), req)),
- );
+ .with_state(shared.clone());
let app = if let Some(ref dist) = cli.dist {
api.fallback_service(ServeDir::new(dist).fallback(ServeFile::new(dist.join("index.html"))))
@@ -621,7 +526,7 @@ async fn main() -> anyhow::Result<()> {
.layer(middleware::from_fn(auth::auth_middleware))
.layer(middleware::from_fn(
move |req: axum::extract::Request, next: middleware::Next| {
- let st = state_crawler.clone();
+ let st = s_crawler.load_state();
async move {
// Inject state into request extensions for auth + OG middleware
let (mut parts, body) = req.into_parts();
diff --git a/server-rs/src/metrics.rs b/server-rs/src/metrics.rs
index 2a54d09..156645c 100644
--- a/server-rs/src/metrics.rs
+++ b/server-rs/src/metrics.rs
@@ -4,12 +4,47 @@ use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use metrics::{counter, gauge, histogram};
-use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
+use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
use std::time::Instant;
/// Initialize the Prometheus metrics exporter and return a handle for rendering metrics.
+///
+/// Configures histogram bucket boundaries so the exporter renders Prometheus histograms
+/// (with `_bucket` suffix) instead of summaries. Without this, `histogram_quantile()`
+/// queries in Grafana find no `_bucket` metrics and return empty.
pub fn init_metrics() -> PrometheusHandle {
+ // Standard Prometheus buckets for HTTP latencies (seconds)
+ const LATENCY_BUCKETS: &[f64] = &[
+ 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
+ ];
+ // Wider buckets for screenshot generation (can take 30s+)
+ const SCREENSHOT_BUCKETS: &[f64] = &[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0];
+ // Count-based buckets for response sizes (number of hexagons/postcodes returned)
+ const RESPONSE_COUNT_BUCKETS: &[f64] = &[
+ 10.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0,
+ ];
+
PrometheusBuilder::new()
+ .set_buckets_for_metric(
+ Matcher::Full("http_request_duration_seconds".to_string()),
+ LATENCY_BUCKETS,
+ )
+ .expect("Failed to set HTTP latency buckets")
+ .set_buckets_for_metric(
+ Matcher::Full("screenshot_duration_seconds".to_string()),
+ SCREENSHOT_BUCKETS,
+ )
+ .expect("Failed to set screenshot duration buckets")
+ .set_buckets_for_metric(
+ Matcher::Full("hexagons_response_count".to_string()),
+ RESPONSE_COUNT_BUCKETS,
+ )
+ .expect("Failed to set hexagons response count buckets")
+ .set_buckets_for_metric(
+ Matcher::Full("postcodes_response_count".to_string()),
+ RESPONSE_COUNT_BUCKETS,
+ )
+ .expect("Failed to set postcodes response count buckets")
.install_recorder()
.expect("Failed to install Prometheus recorder")
}
diff --git a/server-rs/src/og_middleware.rs b/server-rs/src/og_middleware.rs
index 81e4227..89d6ba8 100644
--- a/server-rs/src/og_middleware.rs
+++ b/server-rs/src/og_middleware.rs
@@ -65,6 +65,14 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
format!("{}/api/screenshot?og=1&{}", state.public_url, query_string)
};
+ let og_url = if query_string.is_empty() {
+ format!("{}{}", state.public_url, path)
+ } else {
+ format!("{}{}?{}", state.public_url, path, query_string)
+ };
+
+ let og_logo = format!("{}/favicon.svg", state.public_url);
+
let (og_title, og_description) = if is_invite {
(
"You\u{2019}re invited to Perfect Postcode",
@@ -81,6 +89,8 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
r#"
+
+
diff --git a/server-rs/src/parsing/fields.rs b/server-rs/src/parsing/fields.rs
index 2ea2e3c..772bed5 100644
--- a/server-rs/src/parsing/fields.rs
+++ b/server-rs/src/parsing/fields.rs
@@ -4,8 +4,9 @@ use axum::http::StatusCode;
use rustc_hash::FxHashMap;
/// Parse an optional `?fields=` query param into feature indices for selective aggregation.
-/// Returns `None` if fields is `None` (all features included), or `Some(indices)` if specified.
-/// Returns an error if any field name is unknown.
+/// Returns `None` if fields param is absent (all features included).
+/// Returns `Some(vec![])` if fields is present but empty (no features — count only).
+/// Returns `Some(indices)` for named fields. Errors on unknown field names.
pub fn parse_field_indices(
fields: Option<&str>,
name_to_index: &FxHashMap,
@@ -14,7 +15,7 @@ pub fn parse_field_indices(
return Ok(None);
};
if fields_str.is_empty() {
- return Ok(None);
+ return Ok(Some(vec![]));
}
let mut indices = Vec::new();
for name in fields_str.split(',') {
diff --git a/server-rs/src/parsing/filters.rs b/server-rs/src/parsing/filters.rs
index 7991693..04fde0e 100644
--- a/server-rs/src/parsing/filters.rs
+++ b/server-rs/src/parsing/filters.rs
@@ -54,16 +54,21 @@ pub fn parse_filters(
// Check if this is an enum feature
if let Some(values) = enum_values.get(&feat_idx) {
// Enum filter: convert string values to u16 indices
- let allowed: FxHashSet = rest
- .split('|')
- .filter_map(|value| {
- let value = value.trim();
- values
- .iter()
- .position(|existing| existing == value)
- .map(|position| position as u16)
- })
- .collect();
+ let mut allowed: FxHashSet = FxHashSet::default();
+ for value in rest.split('|') {
+ let value = value.trim();
+ match values.iter().position(|existing| existing == value) {
+ Some(position) => {
+ allowed.insert(position as u16);
+ }
+ None => {
+ return Err(format!(
+ "Unknown value '{}' for enum feature '{}'. Valid values: {:?}",
+ value, name, values
+ ));
+ }
+ }
+ }
enums.push(ParsedEnumFilter { feat_idx, allowed });
} else {
// Numeric filter: parse min:max and encode to u16
@@ -369,20 +374,16 @@ mod tests {
}
#[test]
- fn parse_enum_with_unknown_value() {
+ fn parse_enum_with_unknown_value_errors() {
let tq = test_quant(4, 2);
- let (_numeric, enums) = parse_filters(
+ let result = parse_filters(
Some("Type:Detached|Unknown|Flats/Maisonettes"),
&extended_feature_map(),
&extended_enum_values(),
&tq.as_ref(),
- )
- .unwrap();
-
- assert_eq!(enums.len(), 1);
- assert!(enums[0].allowed.contains(&0)); // Detached
- assert!(enums[0].allowed.contains(&3)); // Flats/Maisonettes
- assert_eq!(enums[0].allowed.len(), 2);
+ );
+ assert!(result.is_err());
+ assert!(result.unwrap_err().contains("Unknown value 'Unknown'"));
}
#[test]
diff --git a/server-rs/src/pocketbase.rs b/server-rs/src/pocketbase.rs
index f36c223..6174912 100644
--- a/server-rs/src/pocketbase.rs
+++ b/server-rs/src/pocketbase.rs
@@ -1,13 +1,62 @@
use std::sync::Arc;
-use std::time::Duration;
+use std::time::{Duration, Instant};
use metrics::gauge;
+use parking_lot::RwLock;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::state::AppState;
+/// Cache TTL for the superuser token. PocketBase superuser JWTs are valid for
+/// ~14 days by default, so 10 minutes is very conservative while eliminating
+/// nearly all redundant auth requests (metrics poller, newsletter, invites, etc.).
+const SUPERUSER_TOKEN_TTL_SECS: u64 = 600;
+
+pub struct SuperuserTokenCache {
+ token: RwLock>,
+}
+
+impl SuperuserTokenCache {
+ pub fn new() -> Self {
+ Self {
+ token: RwLock::new(None),
+ }
+ }
+}
+
+/// Get a cached superuser token, or authenticate fresh if expired/missing.
+pub async fn get_superuser_token(state: &AppState) -> anyhow::Result {
+ // Check cache first (read lock — cheap, non-blocking for other readers)
+ {
+ let cached = state.superuser_token_cache.token.read();
+ if let Some((token, created)) = cached.as_ref() {
+ if created.elapsed().as_secs() < SUPERUSER_TOKEN_TTL_SECS {
+ return Ok(token.clone());
+ }
+ }
+ }
+
+ // Cache miss or expired — fetch a fresh token
+ let pb_url = state.pocketbase_url.trim_end_matches('/');
+ let token = auth_superuser(
+ &state.http_client,
+ pb_url,
+ &state.pocketbase_admin_email,
+ &state.pocketbase_admin_password,
+ )
+ .await?;
+
+ // Store in cache
+ {
+ let mut cached = state.superuser_token_cache.token.write();
+ *cached = Some((token.clone(), Instant::now()));
+ }
+
+ Ok(token)
+}
+
#[derive(Deserialize)]
struct AuthResponse {
token: String,
@@ -105,6 +154,20 @@ impl Field {
}
}
+ fn number(name: &str) -> Self {
+ Self {
+ name: name.to_string(),
+ r#type: "number".to_string(),
+ required: None,
+ max_select: None,
+ collection_id: None,
+ max_size: None,
+ mime_types: None,
+ on_create: None,
+ on_update: None,
+ }
+ }
+
fn autodate(name: &str, on_create: bool, on_update: bool) -> Self {
Self {
name: name.to_string(),
@@ -668,6 +731,39 @@ pub async fn ensure_collections(
ensure_autodate_fields(client, base_url, &token, "short_urls").await?;
}
+ if !existing.iter().any(|n| n == "ai_query_logs") {
+ let users_id = find_users_collection_id(client, base_url, &token).await?;
+ create_collection(
+ client,
+ base_url,
+ &token,
+ CreateCollection {
+ name: "ai_query_logs".to_string(),
+ r#type: "base".to_string(),
+ fields: vec![
+ Field::relation("user", &users_id),
+ Field::text("query", true),
+ Field::text("listing_type", false),
+ Field::text("response_filters", false),
+ Field::text("response_notes", false),
+ Field::number("tokens_used"),
+ Field::number("rounds"),
+ Field::text("model", false),
+ Field::autodate("created", true, false),
+ Field::autodate("updated", true, true),
+ ],
+ list_rule: None,
+ view_rule: None,
+ create_rule: None,
+ update_rule: None,
+ delete_rule: None,
+ },
+ )
+ .await?;
+ } else {
+ ensure_autodate_fields(client, base_url, &token, "ai_query_logs").await?;
+ }
+
Ok(())
}
@@ -763,32 +859,26 @@ pub async fn ensure_oauth_providers(
/// Spawn a background task that polls PocketBase every 60 seconds for collection counts
/// and exposes them as Prometheus gauges.
-pub fn start_metrics_poller(state: Arc) {
+pub fn start_metrics_poller(shared: Arc) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
loop {
interval.tick().await;
+ let state = shared.load_state();
poll_pocketbase_counts(&state).await;
}
});
}
async fn poll_pocketbase_counts(state: &AppState) {
- let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = match auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await
- {
+ let token = match get_superuser_token(state).await {
Ok(tk) => tk,
Err(err) => {
warn!("PocketBase metrics poll auth failed: {err}");
return;
}
};
+ let pb_url = state.pocketbase_url.trim_end_matches('/');
// Simple collection counts
for (collection, metric_name) in [
@@ -815,18 +905,67 @@ async fn poll_pocketbase_counts(state: &AppState) {
("type", "referral"),
),
(
- Some(r#"used_by_id!=""#),
+ Some(r#"used_by_id!="""#),
"invites_total",
("type", "redeemed"),
),
] {
- if let Some(total) = pb_count(&state.http_client, pb_url, &token, "invites", filter).await
- {
+ if let Some(total) = pb_count(&state.http_client, pb_url, &token, "invites", filter).await {
gauge!(metric, labels.0 => labels.1.to_string()).set(total as f64);
}
}
}
+/// Insert a record into the `ai_query_logs` collection.
+/// Best-effort — logs warnings on failure but does not propagate errors.
+#[allow(clippy::too_many_arguments)]
+pub async fn log_ai_query(
+ state: &AppState,
+ user_id: &str,
+ query: &str,
+ listing_type: &str,
+ response_filters: &str,
+ response_notes: &str,
+ tokens_used: u64,
+ rounds: u64,
+) {
+ let token = match get_superuser_token(state).await {
+ Ok(tk) => tk,
+ Err(err) => {
+ warn!("Failed to auth superuser for AI query log: {err}");
+ return;
+ }
+ };
+
+ let pb_url = state.pocketbase_url.trim_end_matches('/');
+ let url = format!("{pb_url}/api/collections/ai_query_logs/records");
+ let res = state
+ .http_client
+ .post(&url)
+ .header("Authorization", format!("Bearer {token}"))
+ .json(&serde_json::json!({
+ "user": user_id,
+ "query": query,
+ "listing_type": listing_type,
+ "response_filters": response_filters,
+ "response_notes": response_notes,
+ "tokens_used": tokens_used,
+ "rounds": rounds,
+ "model": &state.gemini_model,
+ }))
+ .send()
+ .await;
+
+ match res {
+ Ok(resp) if resp.status().is_success() => {}
+ Ok(resp) => {
+ let status = resp.status();
+ warn!("Failed to log AI query ({status})");
+ }
+ Err(err) => warn!("Failed to log AI query: {err}"),
+ }
+}
+
async fn pb_count(
client: &reqwest::Client,
pb_url: &str,
diff --git a/server-rs/src/routes.rs b/server-rs/src/routes.rs
index c58b78e..f775fe4 100644
--- a/server-rs/src/routes.rs
+++ b/server-rs/src/routes.rs
@@ -16,6 +16,7 @@ mod postcode_stats;
mod postcodes;
pub(crate) mod pricing;
pub(crate) mod properties;
+mod reload;
mod screenshot;
mod shorten;
mod stats;
@@ -45,6 +46,7 @@ pub use postcode_stats::get_postcode_stats;
pub use postcodes::{get_postcode_lookup, get_postcodes};
pub use pricing::get_pricing;
pub use properties::get_hexagon_properties;
+pub use reload::post_reload;
pub use screenshot::{fetch_screenshot_bytes, get_screenshot};
pub use shorten::{get_short_url, post_shorten};
pub use streetview::get_streetview;
diff --git a/server-rs/src/routes/ai_filters.rs b/server-rs/src/routes/ai_filters.rs
index dc80c42..5616df6 100644
--- a/server-rs/src/routes/ai_filters.rs
+++ b/server-rs/src/routes/ai_filters.rs
@@ -1,19 +1,20 @@
use std::sync::Arc;
+use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Json;
use axum::Extension;
+use metrics::counter;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
-use metrics::counter;
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{AI_FILTERS_MAX_TOKENS, AI_FILTERS_TEMPERATURE, AI_FILTERS_WEEKLY_TOKEN_LIMIT};
use crate::data::slugify;
-use crate::pocketbase::auth_superuser;
+use crate::pocketbase::{get_superuser_token, log_ai_query};
use crate::routes::{FeatureInfo, FeaturesResponse};
-use crate::state::AppState;
+use crate::state::{AppState, SharedState};
use crate::utils::gemini_chat;
#[derive(Deserialize)]
@@ -36,6 +37,8 @@ pub struct AiFiltersRequest {
query: String,
/// Current filters for conversational refinement (e.g. "make it cheaper")
context: Option,
+ /// Current listing mode (historical/buy/rent). Defaults to "historical".
+ listing_type: Option,
}
#[derive(Serialize)]
@@ -57,6 +60,8 @@ pub struct AiFiltersResponse {
/// What the LLM couldn't map to existing filters (empty if everything matched)
#[serde(skip_serializing_if = "String::is_empty")]
notes: String,
+ /// The listing mode used for this response (historical/buy/rent)
+ listing_type: String,
}
/// Strip markdown code fences (```json ... ``` or ``` ... ```) from LLM output.
@@ -267,6 +272,37 @@ pub fn build_system_prompt(
modes_list,
));
+ // Listing modes section
+ parts.push(
+ "\n--- LISTING MODES ---\n\
+ There are three listing modes that control which property data is shown:\n\
+ - \"historical\": Historical sales from Land Registry (default). Uses features like \
+ \"Last known price\", \"Estimated current price\", \"Price per sqm\".\n\
+ - \"buy\": Properties currently listed for sale. Uses features like \"Asking price\", \
+ \"Asking price per sqm\".\n\
+ - \"rent\": Properties currently listed for rent. Uses features like \
+ \"Asking rent (monthly)\".\n\
+ \n\
+ When the user mentions buying, purchasing, for-sale properties, or asking prices, \
+ set listing_type to \"buy\".\n\
+ When the user mentions renting, letting, rental properties, or monthly rent, \
+ set listing_type to \"rent\".\n\
+ When the user doesn't specify or mentions historical prices/past sales, \
+ omit listing_type to keep the current mode.\n\
+ \n\
+ Features marked with [mode] below are only available in that mode. \
+ Features without a mode annotation work in all modes. \
+ ONLY use features valid for the chosen listing_type.\n\
+ If the user mentions price and the mode is \"buy\", use \"Asking price\" (not \"Last known price\").\n\
+ If the user mentions rent/price and the mode is \"rent\", use \"Asking rent (monthly)\".\n\
+ \n\
+ Feature equivalences across modes:\n\
+ - \"Estimated current price\" (historical) ↔ \"Asking price\" (buy)\n\
+ - \"Est. price per sqm\" (historical) ↔ \"Asking price per sqm\" (buy)\n\
+ - \"Estimated monthly rent\" (historical) ↔ \"Asking rent (monthly)\" (rent)"
+ .to_string(),
+ );
+
// Feature catalogue
parts.push("\n--- AVAILABLE FEATURES ---\n".to_string());
for group in &features.groups {
@@ -284,11 +320,17 @@ pub fn build_system_prompt(
description,
prefix,
suffix,
+ modes,
..
} => {
+ let mode_str = if modes.is_empty() {
+ String::new()
+ } else {
+ format!(" [{}]", modes.join("/"))
+ };
parts.push(format!(
- "- \"{}\" (numeric, {}{:.0}{} to {}{:.0}{}): {}",
- name, prefix, min, suffix, prefix, max, suffix, description
+ "- \"{}\"{} (numeric, {}{:.0}{} to {}{:.0}{}): {}",
+ name, mode_str, prefix, min, suffix, prefix, max, suffix, description
));
}
FeatureInfo::Enum {
@@ -297,6 +339,10 @@ pub fn build_system_prompt(
description,
..
} => {
+ // Skip Listing status — handled via listing_type field
+ if name == "Listing status" {
+ continue;
+ }
parts.push(format!(
"- \"{}\" (enum, values: [{}]): {}",
name,
@@ -380,10 +426,37 @@ pub fn build_system_prompt(
.to_string(),
);
+ // Examples showing listing mode switching
+ parts.push(
+ "\nUser: \"2 bed flat to rent under £1500/month\"\n\
+ Output: {\"listing_type\": \"rent\", \
+ \"numeric_filters\": [{\"name\": \"Asking rent (monthly)\", \"bound\": \"max\", \"value\": 1500}], \
+ \"enum_filters\": [{\"name\": \"Property type\", \"values\": [\"Flats/Maisonettes\"]}], \
+ \"travel_time_filters\": [], \
+ \"notes\": \"\"}"
+ .to_string(),
+ );
+
+ parts.push(
+ "\nUser: \"3 bed house to buy under 500k with good schools\"\n\
+ Output: {\"listing_type\": \"buy\", \
+ \"numeric_filters\": [{\"name\": \"Asking price\", \"bound\": \"max\", \"value\": 500000}, \
+ {\"name\": \"Good+ primary schools within 5km\", \"bound\": \"min\", \"value\": 5}], \
+ \"enum_filters\": [{\"name\": \"Property type\", \
+ \"values\": [\"Detached\", \"Semi-Detached\", \"Terraced\"]}], \
+ \"travel_time_filters\": [], \
+ \"notes\": \"\"}"
+ .to_string(),
+ );
+
// Output format reminder
parts.push(
"\n--- OUTPUT FORMAT ---\n\
- {\"numeric_filters\": [...], \"enum_filters\": [...], \"travel_time_filters\": [{\"mode\": \"...\", \"slug\": \"...\", \"label\": \"...\", \"bound\": \"min\"|\"max\", \"value\": N}, ...], \"notes\": \"...\"}\n\
+ {\"listing_type\": \"buy\"|\"rent\" (OPTIONAL — only when switching mode), \
+ \"numeric_filters\": [...], \"enum_filters\": [...], \
+ \"travel_time_filters\": [{\"mode\": \"...\", \"slug\": \"...\", \"label\": \"...\", \
+ \"bound\": \"min\"|\"max\", \"value\": N}, ...], \"notes\": \"...\"}\n\
+ - listing_type: include only when the user explicitly wants to buy or rent. Omit to keep current mode.\n\
- travel_time_filters: use ONLY slugs returned by search_destinations. If a place isn't found, mention it in notes.\n\
Respond with ONLY the JSON object. No explanation."
.to_string(),
@@ -408,19 +481,12 @@ async fn fetch_ai_usage(
state: &AppState,
user_id: &str,
) -> Result<(u64, u64), (StatusCode, String)> {
- let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await
- .map_err(|err| {
+ let token = get_superuser_token(state).await.map_err(|err| {
warn!("Failed to auth superuser for AI usage check: {err}");
(StatusCode::BAD_GATEWAY, "Internal error".into())
})?;
+ let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = state
.http_client
@@ -459,15 +525,7 @@ async fn fetch_ai_usage(
/// Update the user's AI token usage in PocketBase.
/// Best-effort — logs warnings on failure but does not propagate errors.
async fn update_ai_usage(state: &AppState, user_id: &str, tokens_used: u64, week: u64) {
- let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = match auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await
- {
+ let token = match get_superuser_token(state).await {
Ok(tk) => tk,
Err(err) => {
warn!("Failed to auth superuser for AI usage update: {err}");
@@ -475,6 +533,7 @@ async fn update_ai_usage(state: &AppState, user_id: &str, tokens_used: u64, week
}
};
+ let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let res = state
.http_client
@@ -501,23 +560,16 @@ async fn update_ai_usage(state: &AppState, user_id: &str, tokens_used: u64, week
const MAX_TOOL_ROUNDS: usize = 5;
pub async fn post_ai_filters(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
Json(req): Json,
) -> Result, (StatusCode, String)> {
+ let state = shared.load_state();
// Auth check
let user = user
.0
.ok_or((StatusCode::UNAUTHORIZED, "Login required".into()))?;
- // Email verification check (skipped in dev mode)
- if !user.verified && !state.is_dev {
- return Err((
- StatusCode::FORBIDDEN,
- "Please verify your email to use AI filters".into(),
- ));
- }
-
// Check weekly token usage
let current_week = current_week_number();
let (stored_tokens, stored_week) = fetch_ai_usage(&state, &user.id).await?;
@@ -539,9 +591,17 @@ pub async fn post_ai_filters(
let tools = build_tool_declarations(&state);
- // Build user message with optional context for conversational refinement
+ // Resolve current listing mode from request
+ let current_mode = req.listing_type.as_deref().unwrap_or("historical");
+ let current_mode = match current_mode {
+ "historical" | "buy" | "rent" => current_mode,
+ _ => "historical",
+ };
+
+ // Build user message with listing mode and optional context for conversational refinement
let user_text = if let Some(ref ctx) = req.context {
let mut msg = String::new();
+ msg.push_str(&format!("Current listing mode: {}\n", current_mode));
msg.push_str("Currently active filters:\n");
msg.push_str(&serde_json::to_string(&ctx.filters).unwrap_or_default());
if !ctx.travel_time.is_empty() {
@@ -559,7 +619,10 @@ pub async fn post_ai_filters(
msg.push_str(&format!("\nUser request: {}", req.query));
msg
} else {
- req.query.clone()
+ format!(
+ "Current listing mode: {}\nUser request: {}",
+ current_mode, req.query
+ )
};
let mut contents = vec![json!({
@@ -685,7 +748,17 @@ pub async fn post_ai_filters(
}
};
- let filters = validate_and_convert(&raw, &state.features_response);
+ // Resolve listing_type: LLM output > request > "historical"
+ let listing_type = raw
+ .get("listing_type")
+ .and_then(|val| val.as_str())
+ .unwrap_or(current_mode);
+ let listing_type = match listing_type {
+ "historical" | "buy" | "rent" => listing_type,
+ _ => current_mode,
+ };
+
+ let mut filters = validate_and_convert(&raw, &state.features_response, listing_type);
let travel_time_filters = validate_travel_time_filters(&raw, &state);
let notes = raw
.get("notes")
@@ -693,6 +766,16 @@ pub async fn post_ai_filters(
.unwrap_or("")
.to_string();
+ // Auto-inject Listing status filter for the chosen mode
+ let listing_value = match listing_type {
+ "buy" => "For sale",
+ "rent" => "For rent",
+ _ => "Historical sale",
+ };
+ if let Value::Object(ref mut map) = filters {
+ map.insert("Listing status".to_string(), json!([listing_value]));
+ }
+
// Update usage with total accumulated tokens
let new_total = tokens_used + total_tokens_accumulated;
update_ai_usage(&state, &user.id, new_total, current_week).await;
@@ -700,10 +783,33 @@ pub async fn post_ai_filters(
counter!("ai_tokens_total").increment(total_tokens_accumulated);
counter!("ai_requests_total", "status" => "success").increment(1);
+ // Log the query to PocketBase (fire-and-forget)
+ let filters_json = serde_json::to_string(&filters).unwrap_or_default();
+ let log_state = state.clone();
+ let log_user_id = user.id.clone();
+ let log_query = req.query.clone();
+ let log_listing_type = listing_type.to_string();
+ let log_notes = notes.clone();
+ let log_rounds = (round + 1) as u64;
+ tokio::spawn(async move {
+ log_ai_query(
+ &log_state,
+ &log_user_id,
+ &log_query,
+ &log_listing_type,
+ &filters_json,
+ &log_notes,
+ total_tokens_accumulated,
+ log_rounds,
+ )
+ .await;
+ });
+
return Ok(Json(AiFiltersResponse {
filters,
travel_time_filters,
notes,
+ listing_type: listing_type.to_string(),
}));
}
@@ -793,10 +899,10 @@ fn validate_travel_time_filters(raw: &Value, state: &AppState) -> Vec Value {
+fn validate_and_convert(raw: &Value, features: &FeaturesResponse, listing_type: &str) -> Value {
let mut result = serde_json::Map::new();
- // Build lookup maps from feature metadata
+ // Build lookup maps from feature metadata, filtering by listing mode
let mut numeric_features: rustc_hash::FxHashMap<&str, (f32, f32)> =
rustc_hash::FxHashMap::default();
let mut enum_features: rustc_hash::FxHashMap<&str, &[String]> =
@@ -805,11 +911,23 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
for group in &features.groups {
for feature in &group.features {
match feature {
- FeatureInfo::Numeric { name, min, max, .. } => {
- numeric_features.insert(name, (*min, *max));
+ FeatureInfo::Numeric {
+ name,
+ min,
+ max,
+ modes,
+ ..
+ } => {
+ // Only include features valid for the chosen listing mode
+ if modes.is_empty() || modes.contains(&listing_type) {
+ numeric_features.insert(name, (*min, *max));
+ }
}
FeatureInfo::Enum { name, values, .. } => {
- enum_features.insert(name, values);
+ // Skip Listing status — handled via auto-injection
+ if name != "Listing status" {
+ enum_features.insert(name, values);
+ }
}
}
}
diff --git a/server-rs/src/routes/checkout.rs b/server-rs/src/routes/checkout.rs
index 463ed74..34bf338 100644
--- a/server-rs/src/routes/checkout.rs
+++ b/server-rs/src/routes/checkout.rs
@@ -1,5 +1,6 @@
use std::sync::Arc;
+use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
@@ -7,8 +8,8 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
-use crate::pocketbase::auth_superuser;
-use crate::state::AppState;
+use crate::pocketbase::get_superuser_token;
+use crate::state::{AppState, SharedState};
use super::pricing::{count_licensed_users, price_for_count};
@@ -25,10 +26,11 @@ struct CheckoutResponse {
/// Create a Stripe Checkout session for the lifetime license (or grant for free if in free tier).
/// Requires authentication. Optionally accepts a referral code to apply a coupon.
pub async fn post_checkout(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
Json(req): Json,
) -> Response {
+ let state = shared.load_state();
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
@@ -86,6 +88,8 @@ pub async fn post_checkout(
state.stripe_referral_coupon_id.clone(),
));
info!(code = %code, "Applying referral coupon to checkout");
+ } else {
+ warn!(code = %code, "Referral code validation failed, proceeding without discount");
}
}
@@ -129,15 +133,9 @@ pub async fn post_checkout(
/// Grant a license by updating the user's subscription to "licensed" in PocketBase.
async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
- let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await?;
+ let token = get_superuser_token(state).await?;
+ let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = state
.http_client
diff --git a/server-rs/src/routes/export.rs b/server-rs/src/routes/export.rs
index 7a45ba4..bbd735a 100644
--- a/server-rs/src/routes/export.rs
+++ b/server-rs/src/routes/export.rs
@@ -2,7 +2,7 @@ use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
-use axum::extract::Query;
+use axum::extract::{Query, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::Extension;
@@ -17,7 +17,7 @@ use crate::data::QuantRef;
use crate::licensing::check_license_bounds;
use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters};
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
-use crate::state::AppState;
+use crate::state::SharedState;
const MAX_EXPORT_POSTCODES: usize = 250;
/// Height (in pixels) reserved for the screenshot row
@@ -125,11 +125,12 @@ fn build_frontend_params(
}
pub async fn get_export(
- state: Arc,
+ State(shared): State>,
headers: HeaderMap,
Extension(user): Extension,
Query(params): Query,
) -> Result {
+ let state = shared.load_state();
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
diff --git a/server-rs/src/routes/features.rs b/server-rs/src/routes/features.rs
index bde721c..20c082f 100644
--- a/server-rs/src/routes/features.rs
+++ b/server-rs/src/routes/features.rs
@@ -1,12 +1,13 @@
use std::sync::Arc;
+use axum::extract::State;
use axum::response::Json;
use serde::Serialize;
use tracing::info;
use crate::data::{Histogram, PropertyData};
use crate::features::{ENUM_FEATURE_GROUPS, FEATURE_GROUPS};
-use crate::state::AppState;
+use crate::state::SharedState;
fn is_empty(val: &str) -> bool {
val.is_empty()
@@ -154,7 +155,8 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
FeaturesResponse { groups }
}
-pub async fn get_features(state: Arc) -> Json {
+pub async fn get_features(State(shared): State>) -> Json {
+ let state = shared.load_state();
info!("GET /api/features");
Json(state.features_response.clone())
}
diff --git a/server-rs/src/routes/hexagon_stats.rs b/server-rs/src/routes/hexagon_stats.rs
index 362cdbb..38d1c4b 100644
--- a/server-rs/src/routes/hexagon_stats.rs
+++ b/server-rs/src/routes/hexagon_stats.rs
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
-use axum::extract::Query;
+use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
@@ -16,7 +16,7 @@ use crate::parsing::{
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_set, parse_filters,
row_passes_filters, validate_h3_resolution,
};
-use crate::state::AppState;
+use crate::state::SharedState;
use super::stats;
@@ -79,10 +79,11 @@ pub struct HexagonStatsParams {
}
pub async fn get_hexagon_stats(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
Query(params): Query,
) -> Result, axum::response::Response> {
+ let state = shared.load_state();
let cell = h3o::CellIndex::from_str(¶ms.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
(
diff --git a/server-rs/src/routes/hexagons.rs b/server-rs/src/routes/hexagons.rs
index a10da5b..8fc570d 100644
--- a/server-rs/src/routes/hexagons.rs
+++ b/server-rs/src/routes/hexagons.rs
@@ -1,31 +1,53 @@
use std::sync::Arc;
-use axum::extract::Query;
+use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
+use metrics::histogram;
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
-use metrics::histogram;
use tracing::info;
use crate::aggregation::Aggregator;
use crate::auth::OptionalUser;
-use crate::consts::{DEMO_BOUNDS, MAX_CELLS_PER_REQUEST};
+use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds;
use crate::parsing::{
- bounds_intersect, cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_indices,
- parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
+ cell_for_row_cached, needs_parent, parse_field_indices, parse_filters, require_bounds,
+ row_passes_filters, validate_h3_resolution,
};
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
-use crate::state::AppState;
+use crate::state::SharedState;
/// Row count threshold above which we use rayon parallel aggregation.
const PARALLEL_THRESHOLD: usize = 50_000;
+/// Per-thread aggregation result: feature accumulators + travel time accumulators.
+type ChunkResult = (
+ FxHashMap,
+ Vec>,
+);
+
+/// Maximum center-to-vertex distance in degrees per H3 resolution.
+/// Generous for UK latitudes (49°–61°) so we never false-exclude a visible cell.
+/// Used for cheap center-based bounds filtering instead of computing full cell boundary.
+const H3_CENTER_BUFFERS: [f64; 13] = [
+ 5.0, 2.0, 1.0, 0.5, // res 0–3 (unused in practice)
+ 0.50, // res 4
+ 0.20, // res 5
+ 0.08, // res 6
+ 0.03, // res 7
+ 0.012, // res 8
+ 0.005, // res 9
+ 0.002, // res 10
+ 0.001, // res 11
+ 0.0005, // res 12
+];
+
#[derive(Serialize)]
pub struct HexagonsResponse {
features: Vec>,
@@ -45,7 +67,10 @@ pub struct HexagonParams {
travel: Option,
}
-/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
+/// Build feature maps from aggregated cell data, filtering to only cells whose
+/// center is within the query bounds (expanded by a resolution-dependent buffer).
+/// This is much cheaper than the previous approach of computing full cell boundaries
+/// (6 vertices per cell) — just 4 float comparisons per cell.
#[allow(clippy::too_many_arguments)]
fn build_feature_maps(
groups: &FxHashMap,
@@ -55,44 +80,69 @@ fn build_feature_maps(
num_features: usize,
indices: Option<&[usize]>,
query_bounds: (f64, f64, f64, f64),
+ resolution: h3o::Resolution,
travel_aggs: &[FxHashMap],
travel_field_keys: &[String],
) -> Vec> {
let mut features = Vec::with_capacity(groups.len());
let (q_south, q_west, q_north, q_east) = query_bounds;
+ // Expand bounds by resolution-dependent buffer for center-based filtering
+ let buf = H3_CENTER_BUFFERS[resolution as usize];
+ let bound_south = q_south - buf;
+ let bound_north = q_north + buf;
+ let bound_west = q_west - buf;
+ let bound_east = q_east + buf;
+
+ // Pre-compute travel time key strings (avoids per-cell format!())
+ let travel_keys: Vec<(String, String, String)> = travel_field_keys
+ .iter()
+ .map(|key| {
+ (
+ format!("min_{key}"),
+ format!("max_{key}"),
+ format!("avg_{key}"),
+ )
+ })
+ .collect();
+
+ // Pre-compute default feature indices to avoid per-cell Box allocation
+ let default_indices: Vec;
+ let feat_indices: &[usize] = match indices {
+ Some(idx) => idx,
+ None => {
+ default_indices = (0..num_features).collect();
+ &default_indices
+ }
+ };
+
for (&cell_id, aggregation) in groups {
let Some(cell) = h3o::CellIndex::try_from(cell_id).ok() else {
continue;
};
- // Filter out cells that don't intersect the query bounds
- let (c_south, c_west, c_north, c_east) = h3_cell_bounds(cell, 0.0);
- if !bounds_intersect(
- c_south, c_west, c_north, c_east, q_south, q_west, q_north, q_east,
- ) {
+ // Center is already needed for lat/lon output — reuse for bounds check
+ let center: h3o::LatLng = cell.into();
+ let lat = center.lat();
+ let lng = center.lng();
+
+ // Center-based bounds check: 4 comparisons instead of computing 6 boundary vertices
+ if lat < bound_south || lat > bound_north || lng < bound_west || lng > bound_east {
continue;
}
let mut map = Map::new();
map.insert("h3".into(), Value::String(cell.to_string()));
map.insert("count".into(), Value::Number(aggregation.count.into()));
- let center: h3o::LatLng = cell.into();
- if let (Some(lat), Some(lon)) = (
- serde_json::Number::from_f64(center.lat()),
- serde_json::Number::from_f64(center.lng()),
+ if let (Some(lat_num), Some(lon_num)) = (
+ serde_json::Number::from_f64(lat),
+ serde_json::Number::from_f64(lng),
) {
- map.insert("lat".into(), Value::Number(lat));
- map.insert("lon".into(), Value::Number(lon));
+ map.insert("lat".into(), Value::Number(lat_num));
+ map.insert("lon".into(), Value::Number(lon_num));
}
- let iter: Box> = if let Some(idx) = indices {
- Box::new(idx.iter().copied())
- } else {
- Box::new(0..num_features)
- };
-
- for feat_index in iter {
+ for &feat_index in feat_indices {
if aggregation.feat_counts[feat_index] > 0 {
let avg = aggregation.sums[feat_index] / aggregation.feat_counts[feat_index] as f64;
if let (Some(min_num), Some(max_num), Some(avg_num)) = (
@@ -107,20 +157,19 @@ fn build_feature_maps(
}
}
- // Add travel time aggregation fields
+ // Add travel time aggregation fields (using pre-computed key strings)
for (ti, agg_map) in travel_aggs.iter().enumerate() {
if let Some(agg) = agg_map.get(&cell_id) {
if agg.count > 0 {
- let key = &travel_field_keys[ti];
let avg = agg.sum / agg.count as f64;
if let Some(nm) = serde_json::Number::from_f64(agg.min as f64) {
- map.insert(format!("min_{key}"), Value::Number(nm));
+ map.insert(travel_keys[ti].0.clone(), Value::Number(nm));
}
if let Some(nm) = serde_json::Number::from_f64(agg.max as f64) {
- map.insert(format!("max_{key}"), Value::Number(nm));
+ map.insert(travel_keys[ti].1.clone(), Value::Number(nm));
}
if let Some(nm) = serde_json::Number::from_f64(avg) {
- map.insert(format!("avg_{key}"), Value::Number(nm));
+ map.insert(travel_keys[ti].2.clone(), Value::Number(nm));
}
}
}
@@ -133,20 +182,18 @@ fn build_feature_maps(
}
pub async fn get_hexagons(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
Query(params): Query,
) -> Result, axum::response::Response> {
+ let state = shared.load_state();
let resolution = params.resolution;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
- let is_demo_view = (south, west, north, east) == DEMO_BOUNDS;
- if !is_demo_view {
- check_license_bounds(&user.0, (south, west, north, east))?;
- }
+ check_license_bounds(&user.0, (south, west, north, east))?;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(
@@ -207,19 +254,31 @@ pub async fn get_hexagons(
.map(|_| FxHashMap::default())
.collect();
- // Collect row indices for threshold-based sequential/parallel aggregation
- let row_indices = state.grid.query(south, west, north, east);
+ // O(grid cells) count — no allocation. Used for parallel threshold decision.
+ let row_count = state.grid.count_in_bounds(south, west, north, east);
+ let t_grid = t0.elapsed();
- if row_indices.len() >= PARALLEL_THRESHOLD && !has_travel {
- // Parallel path: split rows across rayon threads, each with local accumulators
+ let parallel = row_count >= PARALLEL_THRESHOLD;
+
+ if parallel {
+ // Parallel: collect row indices for par_chunks, split across rayon threads.
+ // Now handles travel time too (postcode interner & travel data are thread-safe).
+ let row_indices = state.grid.query(south, west, north, east);
let chunk_size = (row_indices.len() / rayon::current_num_threads()).max(1000);
- let thread_results: Vec> = row_indices
+ let thread_results: Vec = row_indices
.par_chunks(chunk_size)
.map(|chunk| {
let mut local_groups: FxHashMap = FxHashMap::default();
+ let mut local_travel_aggs: Vec> = (0
+ ..travel_entries.len())
+ .map(|_| FxHashMap::default())
+ .collect();
let mut h3_cache: FxHashMap = FxHashMap::default();
- for &row_idx in chunk {
+ let mut travel_minutes: Vec> =
+ Vec::with_capacity(travel_entries.len());
+
+ 'row: for &row_idx in chunk {
let row = row_idx as usize;
if !row_passes_filters(
row,
@@ -230,8 +289,40 @@ pub async fn get_hexagons(
) {
continue;
}
- let cell_id =
- cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache);
+
+ if has_travel {
+ travel_minutes.clear();
+ let postcode = pc_interner.resolve(&pc_keys[row]);
+ for (ti, entry) in travel_entries.iter().enumerate() {
+ let row_data = travel_data[ti].get(postcode);
+ let minutes = row_data.map(|r| {
+ if entry.use_best {
+ r.best_minutes.unwrap_or(r.minutes)
+ } else {
+ r.minutes
+ }
+ });
+ travel_minutes.push(minutes);
+ if let (Some(fmin), Some(fmax)) =
+ (entry.filter_min, entry.filter_max)
+ {
+ match minutes {
+ Some(mins)
+ if (mins as f32) >= fmin && (mins as f32) <= fmax => {}
+ _ => continue 'row,
+ }
+ }
+ }
+ }
+
+ let cell_id = cell_for_row_cached(
+ row,
+ precomputed,
+ h3_res,
+ need_parent,
+ &mut h3_cache,
+ );
+
let agg = local_groups
.entry(cell_id)
.or_insert_with(|| Aggregator::new(num_features));
@@ -246,91 +337,108 @@ pub async fn get_hexagons(
} else {
agg.add_row(feature_data, row, num_features, &quant);
}
- }
- local_groups
- })
- .collect();
- // Merge thread-local results into the main groups map
- for local_groups in thread_results {
- for (cell_id, local_agg) in local_groups {
- let agg = groups
- .entry(cell_id)
- .or_insert_with(|| Aggregator::new(num_features));
- agg.merge(&local_agg);
- }
- }
- } else {
- // Sequential path (also handles travel time which needs postcode lookups)
- let mut travel_minutes: Vec > = Vec::with_capacity(travel_entries.len());
- let mut h3_cache: FxHashMap = FxHashMap::default();
-
- 'row: for &row_idx in &row_indices {
- let row = row_idx as usize;
-
- // Regular filters
- if !row_passes_filters(
- row,
- &parsed_filters,
- &parsed_enum_filters,
- feature_data,
- num_features,
- ) {
- continue;
- }
-
- // Travel time filter: check each entry with a range
- if has_travel {
- travel_minutes.clear();
- let postcode = pc_interner.resolve(&pc_keys[row]);
- for (ti, entry) in travel_entries.iter().enumerate() {
- let row_data = travel_data[ti].get(postcode);
- let minutes = row_data.map(|r| {
- if entry.use_best {
- r.best_minutes.unwrap_or(r.minutes)
- } else {
- r.minutes
- }
- });
- travel_minutes.push(minutes);
- if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
- match minutes {
- Some(mins) if (mins as f32) >= fmin && (mins as f32) <= fmax => {}
- _ => continue 'row, // Filtered out (jump to next row_idx)
+ for (ti, minutes) in travel_minutes.iter().enumerate() {
+ if let Some(mins) = minutes {
+ let tagg = local_travel_aggs[ti]
+ .entry(cell_id)
+ .or_insert_with(TravelTimeAgg::new);
+ tagg.add(*mins as f32);
}
}
}
+
+ (local_groups, local_travel_aggs)
+ })
+ .collect();
+
+ // Merge thread-local results into the main accumulators
+ for (local_groups, local_travel) in thread_results {
+ for (cell_id, local_agg) in local_groups {
+ groups
+ .entry(cell_id)
+ .or_insert_with(|| Aggregator::new(num_features))
+ .merge(&local_agg);
}
-
- let cell_id =
- cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache);
-
- // Aggregate regular features
- let aggregation = groups
- .entry(cell_id)
- .or_insert_with(|| Aggregator::new(num_features));
- if let Some(sel_indices) = field_indices.as_deref() {
- aggregation.add_row_selective(
- feature_data,
- row,
- num_features,
- sel_indices,
- &quant,
- );
- } else {
- aggregation.add_row(feature_data, row, num_features, &quant);
- }
-
- // Aggregate travel time
- for (ti, minutes) in travel_minutes.iter().enumerate() {
- if let Some(mins) = minutes {
- let agg = travel_aggs[ti]
+ for (ti, local_ta) in local_travel.into_iter().enumerate() {
+ for (cell_id, local_tt) in local_ta {
+ travel_aggs[ti]
.entry(cell_id)
- .or_insert_with(TravelTimeAgg::new);
- agg.add(*mins as f32);
+ .or_insert_with(TravelTimeAgg::new)
+ .merge(&local_tt);
}
}
}
+ } else {
+ // Sequential: use for_each_in_bounds to avoid Vec allocation
+ let mut travel_minutes: Vec> = Vec::with_capacity(travel_entries.len());
+ let mut h3_cache: FxHashMap = FxHashMap::default();
+
+ state
+ .grid
+ .for_each_in_bounds(south, west, north, east, |row_idx| {
+ let row = row_idx as usize;
+
+ if !row_passes_filters(
+ row,
+ &parsed_filters,
+ &parsed_enum_filters,
+ feature_data,
+ num_features,
+ ) {
+ return;
+ }
+
+ if has_travel {
+ travel_minutes.clear();
+ let postcode = pc_interner.resolve(&pc_keys[row]);
+ for (ti, entry) in travel_entries.iter().enumerate() {
+ let row_data = travel_data[ti].get(postcode);
+ let minutes = row_data.map(|r| {
+ if entry.use_best {
+ r.best_minutes.unwrap_or(r.minutes)
+ } else {
+ r.minutes
+ }
+ });
+ travel_minutes.push(minutes);
+ if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
+ match minutes {
+ Some(mins)
+ if (mins as f32) >= fmin && (mins as f32) <= fmax => {}
+ _ => return,
+ }
+ }
+ }
+ }
+
+ let cell_id =
+ cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache);
+
+ let aggregation = groups
+ .entry(cell_id)
+ .or_insert_with(|| Aggregator::new(num_features));
+ if let Some(sel_indices) = field_indices.as_deref() {
+ aggregation.add_row_selective(
+ feature_data,
+ row,
+ num_features,
+ sel_indices,
+ &quant,
+ );
+ } else {
+ aggregation.add_row(feature_data, row, num_features, &quant);
+ }
+
+ for (ti, minutes) in travel_minutes.iter().enumerate() {
+ if let Some(mins) = minutes {
+ let agg = travel_aggs[ti]
+ .entry(cell_id)
+ .or_insert_with(TravelTimeAgg::new);
+ agg.add(*mins as f32);
+ }
+ }
+ });
};
let t_agg = t0.elapsed();
@@ -343,6 +451,7 @@ pub async fn get_hexagons(
num_features,
field_indices.as_deref(),
(south, west, north, east),
+ h3_res,
&travel_aggs,
&travel_field_keys,
);
@@ -352,11 +461,10 @@ pub async fn get_hexagons(
features.truncate(MAX_CELLS_PER_REQUEST);
}
- let parallel = row_indices.len() >= PARALLEL_THRESHOLD && !has_travel;
let t_total = t0.elapsed();
info!(
resolution,
- rows = row_indices.len(),
+ rows = row_count,
parallel,
cells_before_filter = groups.len(),
cells_after_filter = features.len(),
@@ -364,8 +472,11 @@ pub async fn get_hexagons(
bounds = format_args!("{:.4},{:.4},{:.4},{:.4}", south, west, north, east),
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
+ fields = field_indices.as_ref().map(|v| v.len() as i32).unwrap_or(-1),
travel_entries = travel_entries.len(),
- agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0),
+ grid_ms = format_args!("{:.1}", t_grid.as_secs_f64() * 1000.0),
+ agg_ms = format_args!("{:.1}", (t_agg - t_grid).as_secs_f64() * 1000.0),
+ json_ms = format_args!("{:.1}", (t_total - t_agg).as_secs_f64() * 1000.0),
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
"GET /api/hexagons"
);
diff --git a/server-rs/src/routes/invites.rs b/server-rs/src/routes/invites.rs
index a52a207..f71056f 100644
--- a/server-rs/src/routes/invites.rs
+++ b/server-rs/src/routes/invites.rs
@@ -1,6 +1,6 @@
use std::sync::Arc;
-use axum::extract::Path;
+use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
-use crate::pocketbase::auth_superuser;
-use crate::state::AppState;
+use crate::pocketbase::get_superuser_token;
+use crate::state::SharedState;
#[derive(Serialize)]
struct InviteResponse {
@@ -90,10 +90,11 @@ fn generate_invite_code() -> String {
/// Create an invite. Admins create "admin" invites (free license) by default,
/// but can explicitly request "referral" type. Licensed non-admin users always create "referral" invites (30% off).
pub async fn post_invites(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
Json(body): Json,
) -> Response {
+ let state = shared.load_state();
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
@@ -117,14 +118,7 @@ pub async fn post_invites(
let code = generate_invite_code();
let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = match auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await
- {
+ let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
@@ -179,10 +173,11 @@ const DEV_INVITE_CODE: &str = "devdevdevdev";
/// Validate an invite code. Public endpoint — codes are 12-char random alphanumeric
/// so enumeration is impractical, and the response only reveals valid/invalid + type.
pub async fn get_invite(
- state: Arc,
+ State(shared): State>,
Extension(_user): Extension,
Path(code): Path,
) -> Response {
+ let state = shared.load_state();
if let Err(msg) = validate_invite_code(&code) {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
@@ -200,14 +195,7 @@ pub async fn get_invite(
let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = match auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await
- {
+ let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
@@ -297,10 +285,11 @@ pub async fn get_invite(
/// Admin invite: sets subscription to "licensed" directly.
/// Referral invite: returns a discounted Stripe checkout URL.
pub async fn post_redeem_invite(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
Json(req): Json,
) -> Response {
+ let state = shared.load_state();
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
@@ -322,14 +311,7 @@ pub async fn post_redeem_invite(
let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = match auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await
- {
+ let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
@@ -486,9 +468,10 @@ pub async fn post_redeem_invite(
/// List invites. Admins see all invites; licensed users see only their own.
pub async fn get_invites(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
) -> Response {
+ let state = shared.load_state();
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
@@ -496,14 +479,7 @@ pub async fn get_invites(
let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = match auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await
- {
+ let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
diff --git a/server-rs/src/routes/journey.rs b/server-rs/src/routes/journey.rs
index 835e9ff..31d3db2 100644
--- a/server-rs/src/routes/journey.rs
+++ b/server-rs/src/routes/journey.rs
@@ -1,10 +1,11 @@
use std::sync::Arc;
+use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
-use crate::state::AppState;
+use crate::state::SharedState;
#[derive(Deserialize)]
pub struct JourneyQuery {
@@ -24,9 +25,10 @@ pub struct JourneyResponse {
}
pub async fn get_journey(
- state: Arc,
+ State(shared): State>,
query: axum::extract::Query,
) -> Result, (StatusCode, String)> {
+ let state = shared.load_state();
let store = &state.travel_time_store;
if !store.has_destination(&query.mode, &query.slug) {
diff --git a/server-rs/src/routes/newsletter.rs b/server-rs/src/routes/newsletter.rs
index 04ff8b1..19971de 100644
--- a/server-rs/src/routes/newsletter.rs
+++ b/server-rs/src/routes/newsletter.rs
@@ -1,5 +1,6 @@
use std::sync::Arc;
+use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
@@ -7,8 +8,8 @@ use serde::Deserialize;
use tracing::warn;
use crate::auth::OptionalUser;
-use crate::pocketbase::auth_superuser;
-use crate::state::AppState;
+use crate::pocketbase::get_superuser_token;
+use crate::state::SharedState;
#[derive(Deserialize)]
pub struct UpdateNewsletterRequest {
@@ -16,25 +17,17 @@ pub struct UpdateNewsletterRequest {
}
pub async fn patch_newsletter(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
Json(req): Json,
) -> Response {
+ let state = shared.load_state();
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
- let pb_url = state.pocketbase_url.trim_end_matches('/');
-
- let token = match auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await
- {
+ let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to authenticate as PocketBase superuser: {err}");
@@ -42,6 +35,7 @@ pub async fn patch_newsletter(
}
};
+ let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{}", user.id);
let res = state
.http_client
diff --git a/server-rs/src/routes/pb_proxy.rs b/server-rs/src/routes/pb_proxy.rs
index 4655d8d..22848fc 100644
--- a/server-rs/src/routes/pb_proxy.rs
+++ b/server-rs/src/routes/pb_proxy.rs
@@ -2,12 +2,12 @@ use std::sync::{Arc, LazyLock};
use std::time::Duration;
use axum::body::Body;
-use axum::extract::Request;
+use axum::extract::{Request, State};
use axum::http::{HeaderName, StatusCode};
use axum::response::{IntoResponse, Response};
use tracing::warn;
-use crate::state::AppState;
+use crate::state::SharedState;
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx
/// responses are passed through to the browser (needed for OAuth flows).
@@ -22,7 +22,11 @@ static PROXY_CLIENT: LazyLock = LazyLock::new(|| {
.expect("Failed to build proxy HTTP client")
});
-pub async fn proxy_to_pocketbase(state: Arc, req: Request) -> impl IntoResponse {
+pub async fn proxy_to_pocketbase(
+ State(shared): State>,
+ req: Request,
+) -> impl IntoResponse {
+ let state = shared.load_state();
let pb_url = state.pocketbase_url.trim_end_matches('/');
let path = req.uri().path();
diff --git a/server-rs/src/routes/places.rs b/server-rs/src/routes/places.rs
index d8b75bc..9de9a7a 100644
--- a/server-rs/src/routes/places.rs
+++ b/server-rs/src/routes/places.rs
@@ -1,13 +1,13 @@
use std::sync::Arc;
-use axum::extract::Query;
+use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::data::slugify;
-use crate::state::AppState;
+use crate::state::SharedState;
#[derive(Serialize)]
pub struct PlaceResult {
@@ -35,9 +35,10 @@ pub struct PlacesParams {
}
pub async fn get_places(
- state: Arc,
+ State(shared): State>,
Query(params): Query,
) -> Result, (StatusCode, String)> {
+ let state = shared.load_state();
let query = if params.q.is_empty() {
return Err((StatusCode::BAD_REQUEST, "'q' must not be empty".into()));
} else {
diff --git a/server-rs/src/routes/pois.rs b/server-rs/src/routes/pois.rs
index 8044586..db7b617 100644
--- a/server-rs/src/routes/pois.rs
+++ b/server-rs/src/routes/pois.rs
@@ -1,6 +1,6 @@
use std::sync::Arc;
-use axum::extract::Query;
+use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
@@ -9,7 +9,7 @@ use tracing::info;
use crate::consts::MAX_POIS_PER_REQUEST;
use crate::data::POICategoryGroup;
use crate::parsing::require_bounds;
-use crate::state::AppState;
+use crate::state::SharedState;
#[derive(Serialize)]
#[allow(clippy::upper_case_acronyms)]
@@ -36,9 +36,10 @@ pub struct POIParams {
}
pub async fn get_pois(
- state: Arc,
+ State(shared): State>,
Query(params): Query,
) -> Result, (StatusCode, String)> {
+ let state = shared.load_state();
let (south, west, north, east) = require_bounds(params.bounds)?;
let category_filter: Option> = params
@@ -127,8 +128,11 @@ pub struct POICategoriesResponse {
groups: Vec,
}
-pub async fn get_poi_categories(state: Arc) -> Json {
- let groups: Vec = state.poi_category_groups.clone();
+pub async fn get_poi_categories(
+ State(shared): State>,
+) -> Json {
+ let state = shared.load_state();
+ let groups: Vec = state.poi_category_groups.to_vec();
let total: usize = groups.iter().map(|group| group.categories.len()).sum();
info!(
diff --git a/server-rs/src/routes/postcode_properties.rs b/server-rs/src/routes/postcode_properties.rs
index 4043923..c6f6214 100644
--- a/server-rs/src/routes/postcode_properties.rs
+++ b/server-rs/src/routes/postcode_properties.rs
@@ -1,6 +1,6 @@
use std::sync::Arc;
-use axum::extract::Query;
+use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
@@ -11,7 +11,7 @@ use crate::auth::OptionalUser;
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT, POSTCODE_SEARCH_OFFSET};
use crate::licensing::check_license_point;
use crate::parsing::{parse_filters, row_passes_filters};
-use crate::state::AppState;
+use crate::state::SharedState;
use crate::utils::normalize_postcode;
use super::properties::{HexagonPropertiesResponse, Property};
@@ -25,10 +25,11 @@ pub struct PostcodePropertiesParams {
}
pub async fn get_postcode_properties(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
Query(params): Query,
) -> Result, axum::response::Response> {
+ let state = shared.load_state();
let normalized = normalize_postcode(¶ms.postcode);
let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) {
diff --git a/server-rs/src/routes/postcode_stats.rs b/server-rs/src/routes/postcode_stats.rs
index fdab6f5..1351aa0 100644
--- a/server-rs/src/routes/postcode_stats.rs
+++ b/server-rs/src/routes/postcode_stats.rs
@@ -1,6 +1,6 @@
use std::sync::Arc;
-use axum::extract::Query;
+use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
@@ -11,7 +11,7 @@ use crate::auth::OptionalUser;
use crate::consts::POSTCODE_SEARCH_OFFSET;
use crate::licensing::check_license_point;
use crate::parsing::{parse_field_set, parse_filters, row_passes_filters};
-use crate::state::AppState;
+use crate::state::SharedState;
use crate::utils::normalize_postcode;
use super::hexagon_stats::HexagonStatsResponse;
@@ -27,10 +27,11 @@ pub struct PostcodeStatsParams {
}
pub async fn get_postcode_stats(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
Query(params): Query,
) -> Result, axum::response::Response> {
+ let state = shared.load_state();
let normalized = normalize_postcode(¶ms.postcode);
// Look up postcode centroid for spatial search
diff --git a/server-rs/src/routes/postcodes.rs b/server-rs/src/routes/postcodes.rs
index 30b81bf..9332a10 100644
--- a/server-rs/src/routes/postcodes.rs
+++ b/server-rs/src/routes/postcodes.rs
@@ -1,13 +1,13 @@
use std::sync::Arc;
-use axum::extract::{Path, Query};
+use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
+use metrics::histogram;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
-use metrics::histogram;
use tracing::info;
use crate::aggregation::Aggregator;
@@ -19,7 +19,7 @@ use crate::parsing::{
bounds_intersect, parse_field_indices, parse_filters, require_bounds, row_passes_filters,
};
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
-use crate::state::AppState;
+use crate::state::SharedState;
use crate::utils::normalize_postcode;
#[derive(Serialize)]
@@ -40,10 +40,11 @@ pub struct PostcodeParams {
}
pub async fn get_postcodes(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
Query(params): Query,
) -> Result, axum::response::Response> {
+ let state = shared.load_state();
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
@@ -177,6 +178,8 @@ pub async fn get_postcodes(
}
}
+ let t_agg = t0.elapsed();
+
// Build response, filtering postcodes to only those whose polygon intersects query bounds
let mut features = Vec::with_capacity(postcode_aggs.len());
let postcodes_before_filter = postcode_aggs.len();
@@ -278,7 +281,7 @@ pub async fn get_postcodes(
histogram!("postcodes_response_count").record(features.len() as f64);
- let truncated = features.len() > MAX_CELLS_PER_REQUEST;
+ let truncated = features.len() >= MAX_CELLS_PER_REQUEST;
let t_total = t0.elapsed();
info!(
postcodes_before_filter,
@@ -288,7 +291,10 @@ pub async fn get_postcodes(
bounds = format_args!("{:.6},{:.6},{:.6},{:.6}", south, west, north, east),
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
+ fields = field_indices.as_ref().map(|v| v.len() as i32).unwrap_or(-1),
travel_entries = travel_entries.len(),
+ agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0),
+ json_ms = format_args!("{:.1}", (t_total - t_agg).as_secs_f64() * 1000.0),
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
"GET /api/postcodes"
);
@@ -307,9 +313,10 @@ pub async fn get_postcodes(
/// Look up a single postcode and return its centroid coordinates and geometry.
pub async fn get_postcode_lookup(
- state: Arc,
+ State(shared): State>,
Path(postcode): Path,
) -> Result, StatusCode> {
+ let state = shared.load_state();
let normalized = normalize_postcode(&postcode);
let postcode_data = &state.postcode_data;
diff --git a/server-rs/src/routes/pricing.rs b/server-rs/src/routes/pricing.rs
index 0008b07..872ee24 100644
--- a/server-rs/src/routes/pricing.rs
+++ b/server-rs/src/routes/pricing.rs
@@ -1,13 +1,14 @@
use std::sync::Arc;
+use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use tracing::warn;
-use crate::pocketbase::auth_superuser;
-use crate::state::AppState;
+use crate::pocketbase::get_superuser_token;
+use crate::state::{AppState, SharedState};
/// Pricing tiers: (cumulative user cap, price in pence).
const TIERS: &[(u64, u64)] = &[
@@ -44,15 +45,9 @@ pub fn price_for_count(count: u64) -> u64 {
/// Count users with subscription="licensed" in PocketBase.
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result {
- let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await?;
+ let token = get_superuser_token(state).await?;
+ let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = "subscription=\"licensed\"";
let url = format!(
"{pb_url}/api/collections/users/records?filter={}&perPage=1",
@@ -75,7 +70,8 @@ pub async fn count_licensed_users(state: &AppState) -> anyhow::Result {
Ok(total)
}
-pub async fn get_pricing(state: Arc) -> Response {
+pub async fn get_pricing(State(shared): State>) -> Response {
+ let state = shared.load_state();
let count = match count_licensed_users(&state).await {
Ok(c) => c,
Err(err) => {
diff --git a/server-rs/src/routes/properties.rs b/server-rs/src/routes/properties.rs
index 1796b3a..c499318 100644
--- a/server-rs/src/routes/properties.rs
+++ b/server-rs/src/routes/properties.rs
@@ -1,7 +1,7 @@
use std::str::FromStr;
use std::sync::Arc;
-use axum::extract::Query;
+use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
@@ -17,7 +17,7 @@ use crate::parsing::{
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters,
validate_h3_resolution,
};
-use crate::state::AppState;
+use crate::state::{AppState, SharedState};
#[derive(Deserialize)]
pub struct HexagonPropertiesParams {
@@ -173,10 +173,11 @@ pub fn build_property(
}
pub async fn get_hexagon_properties(
- state: Arc,
+ State(shared): State>,
Extension(user): Extension,
Query(params): Query,
) -> Result, axum::response::Response> {
+ let state = shared.load_state();
let cell = h3o::CellIndex::from_str(¶ms.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
(
diff --git a/server-rs/src/routes/reload.rs b/server-rs/src/routes/reload.rs
new file mode 100644
index 0000000..0d12069
--- /dev/null
+++ b/server-rs/src/routes/reload.rs
@@ -0,0 +1,181 @@
+use std::sync::Arc;
+use std::time::Instant;
+
+use axum::extract::State;
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Json, Response};
+use serde_json::json;
+use tracing::{info, warn};
+
+use crate::consts::GRID_CELL_SIZE;
+use crate::data::{self, PropertyData};
+use crate::metrics::record_data_stats;
+use crate::routes::{build_features_response, build_system_prompt};
+use crate::state::{AppState, SharedState};
+use crate::utils::GridIndex;
+
+pub async fn post_reload(State(shared): State>) -> Response {
+ if !shared.try_start_reload() {
+ return (StatusCode::CONFLICT, "Reload already in progress").into_response();
+ }
+
+ info!("Reload triggered — rebuilding property data");
+ let start = Instant::now();
+
+ // shared is cloned so we retain a reference after spawn_blocking
+ let sh = Arc::clone(&shared);
+ let result = tokio::task::spawn_blocking(move || rebuild_data(&sh, start)).await;
+
+ // Always clear the reload flag
+ shared.finish_reload();
+
+ match result {
+ Ok(Ok((rows, features, elapsed_ms))) => Json(json!({
+ "status": "ok",
+ "rows": rows,
+ "features": features,
+ "elapsed_ms": elapsed_ms,
+ }))
+ .into_response(),
+ Ok(Err(err)) => {
+ warn!("Reload failed: {err:#}");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("{err:#}") })),
+ )
+ .into_response()
+ }
+ Err(err) => {
+ warn!("Reload task panicked: {err}");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Reload task panicked: {err}") })),
+ )
+ .into_response()
+ }
+ }
+}
+
+fn rebuild_data(shared: &SharedState, start: Instant) -> anyhow::Result<(usize, usize, u128)> {
+ let old = shared.load_state();
+
+ // 1. Load PropertyData from parquet files
+ let property_data = PropertyData::load(
+ &shared.properties_path,
+ &shared.postcode_features_path,
+ &shared.listings_buy_path,
+ &shared.listings_rent_path,
+ )?;
+ let row_count = property_data.lat.len();
+ let feature_count = property_data.num_features;
+
+ // 2. Build spatial grid index
+ info!("Reload: building spatial grid index");
+ let grid = GridIndex::build(&property_data.lat, &property_data.lon, GRID_CELL_SIZE);
+
+ // 3. Precompute H3 cells
+ info!("Reload: precomputing H3 cells");
+ let h3_cells = data::precompute_h3(&property_data.lat, &property_data.lon)?;
+
+ // 4. Build feature lookup tables
+ let feature_name_to_index = property_data
+ .feature_names
+ .iter()
+ .enumerate()
+ .map(|(idx, name)| (name.clone(), idx))
+ .collect();
+
+ let min_keys = property_data
+ .feature_names
+ .iter()
+ .map(|n| format!("min_{n}"))
+ .collect();
+ let max_keys = property_data
+ .feature_names
+ .iter()
+ .map(|n| format!("max_{n}"))
+ .collect();
+ let avg_keys = property_data
+ .feature_names
+ .iter()
+ .map(|n| format!("avg_{n}"))
+ .collect();
+
+ // 5. Build features response and AI prompt
+ let features_response = build_features_response(&property_data);
+ let mode_destinations: Vec<(String, usize)> = old
+ .travel_time_store
+ .available_modes
+ .iter()
+ .map(|mode| {
+ let count = old
+ .travel_time_store
+ .destinations
+ .get(mode.as_str())
+ .map(|slugs| slugs.len())
+ .unwrap_or(0);
+ (mode.clone(), count)
+ })
+ .filter(|(_, count)| *count > 0)
+ .collect();
+ let ai_filters_system_prompt = build_system_prompt(&features_response, &mode_destinations);
+
+ // 6. Update data metrics
+ record_data_stats(
+ row_count,
+ old.poi_data.lat.len(),
+ old.postcode_data.postcodes.len(),
+ );
+
+ // 7. Build new AppState, sharing unchanged fields via Arc
+ let new_state = AppState {
+ data: property_data,
+ grid,
+ h3_cells,
+ feature_name_to_index,
+ min_keys,
+ max_keys,
+ avg_keys,
+ features_response,
+ ai_filters_system_prompt,
+
+ // Shared across reloads (Arc clone is cheap)
+ poi_data: Arc::clone(&old.poi_data),
+ poi_grid: Arc::clone(&old.poi_grid),
+ place_data: Arc::clone(&old.place_data),
+ postcode_data: Arc::clone(&old.postcode_data),
+ poi_category_groups: Arc::clone(&old.poi_category_groups),
+ travel_time_store: Arc::clone(&old.travel_time_store),
+ token_cache: Arc::clone(&old.token_cache),
+ superuser_token_cache: Arc::clone(&old.superuser_token_cache),
+
+ // Config (cheap clone)
+ screenshot_url: old.screenshot_url.clone(),
+ public_url: old.public_url.clone(),
+ is_dev: old.is_dev,
+ index_html: old.index_html.clone(),
+ http_client: old.http_client.clone(),
+ pocketbase_url: old.pocketbase_url.clone(),
+ pocketbase_admin_email: old.pocketbase_admin_email.clone(),
+ pocketbase_admin_password: old.pocketbase_admin_password.clone(),
+ gemini_api_key: old.gemini_api_key.clone(),
+ gemini_model: old.gemini_model.clone(),
+ google_maps_api_key: old.google_maps_api_key.clone(),
+ stripe_secret_key: old.stripe_secret_key.clone(),
+ stripe_webhook_secret: old.stripe_webhook_secret.clone(),
+ stripe_referral_coupon_id: old.stripe_referral_coupon_id.clone(),
+ };
+
+ // 8. Atomic swap
+ shared.swap_state(new_state);
+
+ let elapsed = start.elapsed();
+ info!(
+ rows = row_count,
+ features = feature_count,
+ elapsed_ms = elapsed.as_millis(),
+ "Reload complete"
+ );
+
+ Ok((row_count, feature_count, elapsed.as_millis()))
+}
diff --git a/server-rs/src/routes/screenshot.rs b/server-rs/src/routes/screenshot.rs
index e4f06d4..d6aa8f4 100644
--- a/server-rs/src/routes/screenshot.rs
+++ b/server-rs/src/routes/screenshot.rs
@@ -1,12 +1,13 @@
use std::sync::Arc;
+use axum::extract::State;
use axum::http::header::HeaderValue;
use axum::http::{header, HeaderMap, StatusCode, Uri};
use axum::response::IntoResponse;
use metrics::histogram;
use tracing::{info, warn};
-use crate::state::AppState;
+use crate::state::{AppState, SharedState};
/// Fetch a JPEG screenshot from the screenshot service.
/// Used by both the `/api/screenshot` proxy and the xlsx export.
@@ -39,10 +40,11 @@ pub async fn fetch_screenshot_bytes(
}
pub async fn get_screenshot(
- state: Arc,
+ State(shared): State>,
headers: HeaderMap,
uri: Uri,
) -> impl IntoResponse {
+ let state = shared.load_state();
let qs = uri.query().unwrap_or_default();
let auth = headers.get(header::AUTHORIZATION);
let is_og = qs.contains("og=1");
diff --git a/server-rs/src/routes/shorten.rs b/server-rs/src/routes/shorten.rs
index e63b498..8c7d123 100644
--- a/server-rs/src/routes/shorten.rs
+++ b/server-rs/src/routes/shorten.rs
@@ -1,6 +1,6 @@
use std::sync::Arc;
-use axum::extract::Path;
+use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use axum::Json;
@@ -8,8 +8,8 @@ use rand::Rng;
use serde::{Deserialize, Serialize};
use tracing::warn;
-use crate::pocketbase::auth_superuser;
-use crate::state::AppState;
+use crate::pocketbase::get_superuser_token;
+use crate::state::SharedState;
const CODE_LEN: usize = 8;
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
@@ -38,17 +38,14 @@ struct PbRecord {
params: String,
}
-pub async fn post_shorten(state: Arc, Json(req): Json) -> Response {
+pub async fn post_shorten(
+ State(shared): State>,
+ Json(req): Json,
+) -> Response {
+ let state = shared.load_state();
let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = match auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await
- {
+ let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("PocketBase superuser auth failed: {err}");
@@ -92,21 +89,19 @@ pub async fn post_shorten(state: Arc, Json(req): Json)
}
}
-pub async fn get_short_url(state: Arc, Path(code): Path) -> Response {
+pub async fn get_short_url(
+ State(shared): State>,
+ Path(code): Path,
+) -> Response {
+ let state = shared.load_state();
+
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
return StatusCode::BAD_REQUEST.into_response();
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = match auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await
- {
+ let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("PocketBase superuser auth failed: {err}");
diff --git a/server-rs/src/routes/stats.rs b/server-rs/src/routes/stats.rs
index d63525b..6729062 100644
--- a/server-rs/src/routes/stats.rs
+++ b/server-rs/src/routes/stats.rs
@@ -67,9 +67,7 @@ enum FeatureAccum {
global_max: f32,
},
/// Enum: count occurrences per variant index.
- Enum {
- value_counts: Vec,
- },
+ Enum { value_counts: Vec },
/// Feature skipped (not in field_set).
Skip,
}
diff --git a/server-rs/src/routes/streetview.rs b/server-rs/src/routes/streetview.rs
index d611ae3..0b806b3 100644
--- a/server-rs/src/routes/streetview.rs
+++ b/server-rs/src/routes/streetview.rs
@@ -1,11 +1,12 @@
use std::sync::Arc;
+use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use serde::{Deserialize, Serialize};
use tracing::warn;
-use crate::state::AppState;
+use crate::state::SharedState;
#[derive(Deserialize)]
pub struct StreetViewQuery {
@@ -28,9 +29,10 @@ struct StreetViewResponse {
}
pub async fn get_streetview(
- state: Arc,
+ State(shared): State>,
query: axum::extract::Query,
) -> impl IntoResponse {
+ let state = shared.load_state();
let url = format!(
"https://maps.googleapis.com/maps/api/streetview/metadata?location={},{}&radius=1000&source=outdoor&key={}",
query.lat, query.lon, state.google_maps_api_key
diff --git a/server-rs/src/routes/stripe_webhook.rs b/server-rs/src/routes/stripe_webhook.rs
index 092e373..81d10c6 100644
--- a/server-rs/src/routes/stripe_webhook.rs
+++ b/server-rs/src/routes/stripe_webhook.rs
@@ -1,14 +1,15 @@
use std::sync::Arc;
use axum::body::Bytes;
+use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use tracing::{info, warn};
-use crate::pocketbase::auth_superuser;
-use crate::state::AppState;
+use crate::pocketbase::get_superuser_token;
+use crate::state::SharedState;
type HmacSha256 = Hmac;
@@ -30,6 +31,19 @@ fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
_ => return false,
};
+ // Reject webhooks older than 5 minutes to prevent replay attacks
+ if let Ok(ts_secs) = ts.parse::() {
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64;
+ if (now - ts_secs).abs() > 300 {
+ return false;
+ }
+ } else {
+ return false;
+ }
+
// Compute expected signature: HMAC-SHA256(secret, "TIMESTAMP.PAYLOAD")
let signed_payload = format!("{ts}.{}", String::from_utf8_lossy(payload));
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
@@ -49,10 +63,11 @@ fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
/// Handle Stripe webhook events.
/// On `checkout.session.completed`, updates the user's subscription to "licensed".
pub async fn post_stripe_webhook(
- state: Arc,
+ State(shared): State>,
headers: HeaderMap,
body: Bytes,
) -> Response {
+ let state = shared.load_state();
let webhook_secret = &state.stripe_webhook_secret;
let sig_header = match headers
@@ -90,17 +105,13 @@ pub async fn post_stripe_webhook(
warn!("checkout.session.completed missing client_reference_id");
return StatusCode::OK.into_response();
}
+ if !user_id.bytes().all(|b| b.is_ascii_alphanumeric()) || user_id.len() > 20 {
+ warn!(user_id, "Invalid client_reference_id format in webhook");
+ return StatusCode::BAD_REQUEST.into_response();
+ }
// Update user subscription to "licensed" via PocketBase superuser auth
- let pb_url = state.pocketbase_url.trim_end_matches('/');
- let token = match auth_superuser(
- &state.http_client,
- pb_url,
- &state.pocketbase_admin_email,
- &state.pocketbase_admin_password,
- )
- .await
- {
+ let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser in webhook: {err}");
@@ -108,6 +119,7 @@ pub async fn post_stripe_webhook(
}
};
+ let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let res = state
.http_client
diff --git a/server-rs/src/routes/travel_destinations.rs b/server-rs/src/routes/travel_destinations.rs
index eeb4456..518fb96 100644
--- a/server-rs/src/routes/travel_destinations.rs
+++ b/server-rs/src/routes/travel_destinations.rs
@@ -1,6 +1,6 @@
use std::sync::Arc;
-use axum::extract::Query;
+use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use rustc_hash::FxHashSet;
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use tracing::info;
use crate::data::slugify;
-use crate::state::AppState;
+use crate::state::SharedState;
#[derive(Serialize)]
pub struct DestinationResult {
@@ -30,9 +30,10 @@ pub struct DestinationsParams {
}
pub async fn get_travel_destinations(
- state: Arc,
+ State(shared): State>,
Query(params): Query,
) -> Result, (StatusCode, String)> {
+ let state = shared.load_state();
let mode = params.mode;
let destinations = tokio::task::spawn_blocking(move || {
diff --git a/server-rs/src/routes/travel_modes.rs b/server-rs/src/routes/travel_modes.rs
index dc90f51..f4f5421 100644
--- a/server-rs/src/routes/travel_modes.rs
+++ b/server-rs/src/routes/travel_modes.rs
@@ -1,10 +1,11 @@
use std::sync::Arc;
+use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Json;
use serde::Serialize;
-use crate::state::AppState;
+use crate::state::SharedState;
#[derive(Serialize)]
pub struct TravelModeInfo {
@@ -18,8 +19,9 @@ pub struct TravelModesResponse {
}
pub async fn get_travel_modes(
- state: Arc,
+ State(shared): State>,
) -> Result, (StatusCode, String)> {
+ let state = shared.load_state();
let store = &state.travel_time_store;
let modes = store
.available_modes
diff --git a/server-rs/src/routes/travel_time.rs b/server-rs/src/routes/travel_time.rs
index afb7e62..886ba51 100644
--- a/server-rs/src/routes/travel_time.rs
+++ b/server-rs/src/routes/travel_time.rs
@@ -93,4 +93,18 @@ impl TravelTimeAgg {
self.sum += value as f64;
self.count += 1;
}
+
+ /// Merge another aggregator's results into this one (for parallel reduction).
+ pub fn merge(&mut self, other: &TravelTimeAgg) {
+ if other.count > 0 {
+ if other.min < self.min {
+ self.min = other.min;
+ }
+ if other.max > self.max {
+ self.max = other.max;
+ }
+ self.sum += other.sum;
+ self.count += other.count;
+ }
+ }
}
diff --git a/server-rs/src/state.rs b/server-rs/src/state.rs
index 5c1da4f..45a383d 100644
--- a/server-rs/src/state.rs
+++ b/server-rs/src/state.rs
@@ -1,25 +1,25 @@
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
+use parking_lot::RwLock;
use rustc_hash::FxHashMap;
use crate::auth::TokenCache;
use crate::data::{
POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore,
};
+use crate::pocketbase::SuperuserTokenCache;
use crate::routes::FeaturesResponse;
use crate::utils::GridIndex;
pub struct AppState {
+ // --- Rebuilt on reload ---
pub data: PropertyData,
pub grid: GridIndex,
/// h3_cells[row_idx] = precomputed H3 cell ID at max resolution (12).
/// Parent cells for lower resolutions derived via CellIndex::parent().
pub h3_cells: Vec,
- pub poi_data: POIData,
- pub poi_grid: GridIndex,
- pub place_data: PlaceData,
- /// Postcode boundary data for high-zoom rendering
- pub postcode_data: PostcodeData,
/// O(1) lookup: feature name → index in feature_names/feature_data
pub feature_name_to_index: FxHashMap,
/// Precomputed JSON key names: "min_{feature_name}" for each feature
@@ -28,10 +28,27 @@ pub struct AppState {
pub max_keys: Vec,
/// Precomputed JSON key names: "avg_{feature_name}" for each feature
pub avg_keys: Vec,
- /// Precomputed POI category groups (sorted)
- pub poi_category_groups: Vec,
/// Precomputed features response for /api/features endpoint
pub features_response: FeaturesResponse,
+ /// Complete system prompt for AI filters (features + examples + instructions)
+ pub ai_filters_system_prompt: String,
+
+ // --- Shared across reloads (Arc for cheap cloning) ---
+ pub poi_data: Arc,
+ pub poi_grid: Arc,
+ pub place_data: Arc,
+ /// Postcode boundary data for high-zoom rendering
+ pub postcode_data: Arc,
+ /// Precomputed POI category groups (sorted)
+ pub poi_category_groups: Arc>,
+ /// Precomputed travel time data store
+ pub travel_time_store: Arc,
+ /// Token validation cache (60s TTL)
+ pub token_cache: Arc,
+ /// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting
+ pub superuser_token_cache: Arc,
+
+ // --- Config (cheap to clone) ---
/// URL of the screenshot service (e.g. http://screenshot:8002)
pub screenshot_url: String,
/// Public-facing URL for absolute og:image URLs (e.g. https://perfectpostcodes.dev)
@@ -52,12 +69,6 @@ pub struct AppState {
pub gemini_api_key: String,
/// Gemini model name (e.g. gemini-2.0-flash)
pub gemini_model: String,
- /// Precomputed travel time data store
- pub travel_time_store: Arc,
- /// Token validation cache (60s TTL)
- pub token_cache: Arc,
- /// Complete system prompt for AI filters (features + examples + instructions)
- pub ai_filters_system_prompt: String,
/// Google Maps API key for Street View metadata lookups
pub google_maps_api_key: String,
/// Stripe secret key for creating checkout sessions
@@ -67,3 +78,57 @@ pub struct AppState {
/// Stripe Coupon ID for referral discounts
pub stripe_referral_coupon_id: String,
}
+
+/// Wraps AppState with atomic swap capability for hot-reloading.
+/// Route handlers call `load_state()` to get the current snapshot.
+/// The reload endpoint builds a new AppState and swaps it in atomically.
+pub struct SharedState {
+ current: RwLock>,
+ reloading: AtomicBool,
+ /// Paths needed for data reload
+ pub properties_path: PathBuf,
+ pub postcode_features_path: PathBuf,
+ pub listings_buy_path: PathBuf,
+ pub listings_rent_path: PathBuf,
+}
+
+impl SharedState {
+ pub fn new(
+ state: AppState,
+ properties_path: PathBuf,
+ postcode_features_path: PathBuf,
+ listings_buy_path: PathBuf,
+ listings_rent_path: PathBuf,
+ ) -> Self {
+ Self {
+ current: RwLock::new(Arc::new(state)),
+ reloading: AtomicBool::new(false),
+ properties_path,
+ postcode_features_path,
+ listings_buy_path,
+ listings_rent_path,
+ }
+ }
+
+ /// Get the current AppState snapshot. Cheap (Arc clone under a brief read lock).
+ pub fn load_state(&self) -> Arc {
+ self.current.read().clone()
+ }
+
+ /// Atomically swap in a new AppState. Old state is dropped when all references are gone.
+ pub fn swap_state(&self, new_state: AppState) {
+ *self.current.write() = Arc::new(new_state);
+ }
+
+ /// Try to mark reload as in-progress. Returns false if already reloading.
+ pub fn try_start_reload(&self) -> bool {
+ self.reloading
+ .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
+ .is_ok()
+ }
+
+ /// Mark reload as complete.
+ pub fn finish_reload(&self) {
+ self.reloading.store(false, Ordering::Release);
+ }
+}
diff --git a/server-rs/src/utils/grid_index.rs b/server-rs/src/utils/grid_index.rs
index 5a94038..d03a087 100644
--- a/server-rs/src/utils/grid_index.rs
+++ b/server-rs/src/utils/grid_index.rs
@@ -138,6 +138,26 @@ impl GridIndex {
result
}
+ /// Count the number of row indices within the given bounds without allocating.
+ /// O(grid cells in bounds) — much cheaper than query() for threshold decisions.
+ pub fn count_in_bounds(&self, south: f64, west: f64, north: f64, east: f64) -> usize {
+ let Some((row_min, row_max, col_min, col_max)) =
+ self.clamp_bounds(south, west, north, east)
+ else {
+ return 0;
+ };
+
+ let mut count = 0usize;
+ for row in row_min..=row_max {
+ let row_start = row * self.cols;
+ for col in col_min..=col_max {
+ let cell_idx = row_start + col;
+ count += (self.offsets[cell_idx + 1] - self.offsets[cell_idx]) as usize;
+ }
+ }
+ count
+ }
+
#[inline]
pub fn for_each_in_bounds(
&self,
@@ -334,4 +354,27 @@ mod tests {
let result = grid.query(-90.0, -180.0, 90.0, 180.0);
assert_eq!(result.len(), 2);
}
+
+ #[test]
+ fn count_in_bounds_matches_query_len() {
+ let lat = vec![51.5_f32, 51.6, 51.7, 52.0];
+ let lon = vec![-0.1_f32, -0.1, -0.1, -0.1];
+ let grid = GridIndex::build(&lat, &lon, 0.01);
+
+ let bounds = (51.4, -0.2, 51.8, 0.0);
+ assert_eq!(
+ grid.count_in_bounds(bounds.0, bounds.1, bounds.2, bounds.3),
+ grid.query(bounds.0, bounds.1, bounds.2, bounds.3).len()
+ );
+
+ // Full bounds
+ let full = (50.0, -1.0, 53.0, 1.0);
+ assert_eq!(
+ grid.count_in_bounds(full.0, full.1, full.2, full.3),
+ grid.query(full.0, full.1, full.2, full.3).len()
+ );
+
+ // Empty bounds
+ assert_eq!(grid.count_in_bounds(0.0, 0.0, 1.0, 1.0), 0);
+ }
}