Compare commits

...

6 commits

Author SHA1 Message Date
ebe7bbb51d Fix crime & add actual listings
Some checks failed
CI / Check (push) Failing after 4m1s
Build and publish Docker image / build-and-push (push) Failing after 4m10s
2026-05-17 11:12:25 +01:00
017902b8e6 all good 2026-05-17 10:16:30 +01:00
47d89f6fad Revert homepage 2026-05-16 20:25:53 +01:00
48c13fbcdd Add back finder 2026-05-16 20:22:23 +01:00
5e5d9f9a1c all good 2026-05-16 16:26:36 +01:00
e9a06417ad deploy 2026-05-15 08:17:05 +01:00
112 changed files with 166085 additions and 1439 deletions

1
.gitignore vendored
View file

@ -21,3 +21,4 @@ video/auth.*
*.jpeg
*.mp4
r5-java/tmp

View file

@ -1,6 +1,17 @@
# Stage 1: Build frontend
FROM node:22-bookworm-slim AS frontend
WORKDIR /app/frontend
ARG FRONTEND_BUGSINK_DSN=
ARG BUGSINK_ENVIRONMENT=production
ARG BUGSINK_RELEASE=
ARG BUGSINK_SEND_DEFAULT_PII=true
ENV FRONTEND_BUGSINK_DSN=$FRONTEND_BUGSINK_DSN
ENV BUGSINK_ENVIRONMENT=$BUGSINK_ENVIRONMENT
ENV BUGSINK_RELEASE=$BUGSINK_RELEASE
ENV BUGSINK_SEND_DEFAULT_PII=$BUGSINK_SEND_DEFAULT_PII
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
RUN apt-get update \

View file

@ -100,7 +100,7 @@ download-price-paid: $(PRICE_PAID)
download-deprivation: $(IOD)
download-ethnicity: $(ETHNICITY)
download-crime: $(CRIME_STAMP)
$(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*.csv" --zip-glob "$(CRIME_DIR)/_archives::*.zip"
$(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv"
download-naptan: $(NAPTAN)
download-pois: $(POIS_RAW)
download-grocery-retail-points: $(GROCERY_RETAIL_POINTS)
@ -179,7 +179,7 @@ $(ETHNICITY): pipeline/download/ethnicity.py
$(CRIME_STAMP): $(CRIME_DOWNLOAD_DEPS)
@rm -f $@
uv run python -m pipeline.download.crime --output $(CRIME_DIR)
$(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*.csv" --zip-glob "$(CRIME_DIR)/_archives::*.zip"
$(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv"
@touch $@
$(NAPTAN):
@ -279,7 +279,7 @@ $(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
$(CRIME): $(CRIME_STAMP)
$(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*.csv" --zip-glob "$(CRIME_DIR)/_archives::*.zip"
$(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv"
uv run python -m pipeline.transform.crime --input $(CRIME_DIR) --output $@
$(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(OS_GREENSPACE) $(POI_PROXIMITY_DEPS)

View file

@ -145,6 +145,13 @@ export STRIPE_WEBHOOK_SECRET=...
export STRIPE_REFERRAL_COUPON_ID=...
export GOOGLE_OAUTH_CLIENT_ID=...
export GOOGLE_OAUTH_CLIENT_SECRET=...
# Optional Bugsink/Sentry-compatible error reporting
export BUGSINK_DSN=...
export FRONTEND_BUGSINK_DSN=...
export BUGSINK_ENVIRONMENT=development
export BUGSINK_RELEASE=...
export BUGSINK_SEND_DEFAULT_PII=false
```
```bash
@ -199,3 +206,25 @@ docker build -t property-map .
The container entrypoint runs `property-map-server` with the expected data paths
under `/app/data` and serves `frontend/dist` when `--dist` is present.
## Bugsink
Bugsink is wired through the Sentry-compatible SDKs. Set `BUGSINK_DSN` for the
Rust API and `FRONTEND_BUGSINK_DSN` for the browser app. If the frontend DSN is
omitted, the server falls back to `BUGSINK_DSN` when injecting runtime config
into served HTML.
The frontend build also accepts `FRONTEND_BUGSINK_DSN`, `BUGSINK_ENVIRONMENT`,
`BUGSINK_RELEASE`, and `BUGSINK_SEND_DEFAULT_PII` as build-time values. Runtime
HTML injection is preferred for Docker deployments because the DSN can be set
with environment variables when the container starts.
Production Webpack builds emit hidden source maps. Upload them to Bugsink after
building if you want browser stack traces to resolve to source:
```bash
cd frontend
npx sentry-cli sourcemaps inject dist
SENTRY_AUTH_TOKEN=... npx sentry-cli --url https://your-bugsink-instance \
sourcemaps --org bugsinkhasnoorgs --project ignoredfornow upload dist
```

File diff suppressed because one or more lines are too long

View file

@ -1,125 +1,146 @@
x-credentials:
pb-email: &pb-email admin@propertymap.local
pb-password: &pb-password propertymap-dev-2024
pb-email: &pb-email admin@propertymap.local
pb-password: &pb-password propertymap-dev-2024
services:
server:
image: rust:1.84
tty: true
stdin_open: true
working_dir: /app/server-rs
command: >
bash -c "
cargo install cargo-watch &&
cargo watch --poll -i logs/ -x 'run -- --properties /app/data/properties.parquet --postcode-features /app/data/postcode.parquet --pois /app/data/filtered_uk_pois.parquet --places /app/data/places.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries --travel-times /app/data/travel-times'
"
ports:
- "8001:8001"
networks:
- dev-network
cap_add:
- IPC_LOCK
ulimits:
memlock:
soft: -1
hard: -1
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- .:/app
- cargo-home:/usr/local/cargo
- cargo-target:/app/server-rs/target
- ./property-data:/app/data:ro
- ./property-data/travel-times:/app/data/travel-times:ro
environment:
POCKETBASE_URL: http://pocketbase:8090
POCKETBASE_ADMIN_EMAIL: *pb-email
POCKETBASE_ADMIN_PASSWORD: *pb-password
SCREENSHOT_URL: http://screenshot:8002
GEMINI_API_KEY: AIzaSyC2mQDcEwILHM3uOE2C-lxUQbQrKTX9Xi4
GEMINI_MODEL: gemini-3-flash-preview
PUBLIC_URL: http://localhost:3001
GOOGLE_MAPS_API_KEY: "AIzaSyBgBn9LjrxHCjb9j1LZbLYpEdCJj-NkHPY"
STRIPE_SECRET_KEY: sk_test_51SyVcePRjj2bdyn1HLkatQ5onwp8kamm41tjMcRdxXnJYWVPsVd9usMTOSNtNdGhrjbsrtNbgTdKXICg2qBiocEn00PvNDC0d3
STRIPE_WEBHOOK_SECRET: whsec_pIkGZblYlcN2VesTxq4pk1cDqdxOQ1y0
STRIPE_REFERRAL_COUPON_ID: L5uQqagl
GOOGLE_OAUTH_CLIENT_ID: 536485512604-740bbn3tf027ogrdcr5sqor4ntorkaqv.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET: 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
depends_on:
screenshot:
condition: service_healthy
pocketbase:
condition: service_healthy
server:
image: rust:1.84
tty: true
stdin_open: true
working_dir: /app/server-rs
command: >
bash -c "
cargo install cargo-watch &&
cargo watch --poll -i logs/ -x 'run -- --properties /app/data/properties.parquet --postcode-features /app/data/postcode.parquet --pois /app/data/filtered_uk_pois.parquet --places /app/data/places.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries --travel-times /app/data/travel-times'
"
ports:
- "8001:8001"
networks:
- dev-network
cap_add:
- IPC_LOCK
ulimits:
memlock:
soft: -1
hard: -1
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- .:/app
- cargo-home:/usr/local/cargo
- cargo-target:/app/server-rs/target
- ./property-data:/app/data:ro
- ./property-data/travel-times:/app/data/travel-times:ro
- ./finder/data:/app/finder-data:ro
environment:
POCKETBASE_URL: http://pocketbase:8090
POCKETBASE_ADMIN_EMAIL: *pb-email
POCKETBASE_ADMIN_PASSWORD: *pb-password
SCREENSHOT_URL: http://screenshot:8002
GEMINI_API_KEY: AIzaSyC2mQDcEwILHM3uOE2C-lxUQbQrKTX9Xi4
GEMINI_MODEL: gemini-3-flash-preview
PUBLIC_URL: http://localhost:3001
GOOGLE_MAPS_API_KEY: "AIzaSyBgBn9LjrxHCjb9j1LZbLYpEdCJj-NkHPY"
STRIPE_SECRET_KEY: sk_test_51SyVcePRjj2bdyn1HLkatQ5onwp8kamm41tjMcRdxXnJYWVPsVd9usMTOSNtNdGhrjbsrtNbgTdKXICg2qBiocEn00PvNDC0d3
STRIPE_WEBHOOK_SECRET: whsec_pIkGZblYlcN2VesTxq4pk1cDqdxOQ1y0
STRIPE_REFERRAL_COUPON_ID: L5uQqagl
GOOGLE_OAUTH_CLIENT_ID: 536485512604-740bbn3tf027ogrdcr5sqor4ntorkaqv.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET: 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
BUGSINK_DSN: ${BUGSINK_DSN:-}
FRONTEND_BUGSINK_DSN: ${FRONTEND_BUGSINK_DSN:-}
BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development}
BUGSINK_RELEASE: ${BUGSINK_RELEASE:-}
BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false}
ACTUAL_LISTINGS_PATH: /app/finder-data/online_listings_buy_filtered.parquet
depends_on:
screenshot:
condition: service_healthy
pocketbase:
condition: service_healthy
screenshot:
init: true
build: ./screenshot
environment:
PORT: "8002"
APP_URL: http://frontend:3001
CACHE_DIR: /cache
SCREENSHOT_CACHE_ENABLED: "false"
SCREENSHOT_CONCURRENCY: "3"
SCREENSHOT_RATE_WINDOW_MS: "60000"
SCREENSHOT_RATE_LIMIT: "30"
volumes:
- screenshot-cache:/cache
networks:
- dev-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
screenshot:
init: true
build: ./screenshot
environment:
PORT: "8002"
APP_URL: http://frontend:3001
CACHE_DIR: /cache
SCREENSHOT_CACHE_ENABLED: "false"
SCREENSHOT_CONCURRENCY: "3"
SCREENSHOT_RATE_WINDOW_MS: "60000"
SCREENSHOT_RATE_LIMIT: "30"
volumes:
- screenshot-cache:/cache
networks:
- dev-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
frontend:
init: true
image: node:22-slim
working_dir: /app/frontend
command: >
bash -c "
npm install &&
npm run dev
"
ports:
- "3001:3001"
networks:
- dev-network
volumes:
- .:/app
- frontend-node-modules:/app/frontend/node_modules
environment:
API_PROXY_TARGET: http://server:8001
PB_PROXY_TARGET: http://pocketbase:8090
pocketbase:
init: true
image: ghcr.io/muchobien/pocketbase:latest
ports:
- "8090:8090"
volumes:
- pb-data:/pb/pb_data
networks:
- dev-network
environment:
PB_ADMIN_EMAIL: *pb-email
PB_ADMIN_PASSWORD: *pb-password
PB_TRUSTED_PROXY: server
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
frontend:
init: true
image: node:22-slim
working_dir: /app/frontend
command: >
bash -c "
npm install &&
npm run dev
"
ports:
- "3001:3001"
networks:
- dev-network
volumes:
- .:/app
- frontend-node-modules:/app/frontend/node_modules
environment:
API_PROXY_TARGET: http://server:8001
PB_PROXY_TARGET: http://pocketbase:8090
CHOKIDAR_USEPOLLING: "true"
CHOKIDAR_INTERVAL: "1000"
WATCHPACK_POLLING: "1000"
FRONTEND_BUGSINK_DSN: ${FRONTEND_BUGSINK_DSN:-}
BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development}
BUGSINK_RELEASE: ${BUGSINK_RELEASE:-}
BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false}
pocketbase:
init: true
image: ghcr.io/muchobien/pocketbase:latest
ports:
- "8090:8090"
volumes:
- pb-data:/pb/pb_data
networks:
- dev-network
environment:
PB_ADMIN_EMAIL: *pb-email
PB_ADMIN_PASSWORD: *pb-password
PB_TRUSTED_PROXY: server
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:8090/api/health",
]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
volumes:
pb-data:
cargo-home:
cargo-target:
frontend-node-modules:
screenshot-cache:
pb-data:
cargo-home:
cargo-target:
frontend-node-modules:
screenshot-cache:
networks:
dev-network:
dev-network:

1
finder/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
data/

143
finder/constants.py Normal file
View file

@ -0,0 +1,143 @@
import os
from pathlib import Path
FINDER_DIR = Path(__file__).resolve().parent
REPO_DIR = FINDER_DIR.parent
DATA_DIR = Path(os.environ.get("DATA_DIR", str(FINDER_DIR / "data")))
ARCGIS_PATH = Path(
os.environ.get("ARCGIS_PATH", str(REPO_DIR / "property-data" / "arcgis_data.parquet"))
)
PAGE_SIZE = 24
DELAY_BETWEEN_PAGES = 0.3
DELAY_BETWEEN_OUTCODES = 0.5
MAX_RETRIES = 3
RETRY_BASE_DELAY = 2.0
GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index
MAX_BEDROOMS = 20 # sanity cap — values above this are almost certainly parsing errors
TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
RIGHTMOVE_BASE = "https://www.rightmove.co.uk"
# home.co.uk
HOMECOUK_BASE = "https://home.co.uk"
HOMECOUK_API_BASE = f"{HOMECOUK_BASE}/api"
HOMECOUK_PER_PAGE = 30 # max supported by the API
# Zoopla
ZOOPLA_BASE = "https://www.zoopla.co.uk"
# Greater London-ish postcode areas. This intentionally uses broad area
# prefixes so a manual scrape can include central/inner London plus common
# outer-London and near-London outcodes without maintaining a long borough list.
LONDON_OUTCODE_PREFIXES = {
"E",
"EC",
"N",
"NW",
"SE",
"SW",
"W",
"WC",
"BR",
"CR",
"DA",
"EN",
"HA",
"IG",
"KT",
"RM",
"SM",
"TW",
"UB",
"WD",
}
PROPERTY_TYPE_MAP = {
"Detached": "Detached",
"Semi-Detached": "Semi-Detached",
"Terraced": "Terraced",
"End of Terrace": "Terraced",
"Mid Terrace": "Terraced",
"Flat": "Flats/Maisonettes",
"Maisonette": "Flats/Maisonettes",
"Studio": "Flats/Maisonettes",
"Apartment": "Flats/Maisonettes",
"Penthouse": "Flats/Maisonettes",
"Ground Flat": "Flats/Maisonettes",
"Duplex": "Flats/Maisonettes",
"Detached Bungalow": "Detached",
"Semi-Detached Bungalow": "Semi-Detached",
"Town House": "Terraced",
"Link Detached": "Detached",
"Link Detached House": "Detached",
"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",
"Coach House": "Other",
"Character Property": "Other",
"Cluster House": "Other",
"Retirement Property": "Flats/Maisonettes",
"Parking": "Other",
"Plot": "Other",
"Garages": "Other",
"Mews": "Terraced",
"Property": "Other",
"Flat Share": "Other",
"Block of Apartments": "Flats/Maisonettes",
"Private Halls": "Flats/Maisonettes",
"Terraced Bungalow": "Terraced",
"Equestrian Facility": "Other",
"Ground Maisonette": "Flats/Maisonettes",
"Country House": "Detached",
"Village House": "Detached",
"Farm Land": "Other",
"House Boat": "Other",
"Barn": "Other",
"Serviced Apartments": "Flats/Maisonettes",
# Space-separated variants (from home.co.uk underscore/hyphen normalization)
"Semi Detached": "Semi-Detached",
"Semi Detached Bungalow": "Semi-Detached",
"End Of Terrace": "Terraced",
"End Terrace": "Terraced",
"Block Of Apartments": "Flats/Maisonettes",
# 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",
"flat": "Flats/Maisonettes",
"detached": "Detached",
"semi-detached": "Semi-Detached",
"terraced": "Terraced",
"maisonette": "Flats/Maisonettes",
"apartment": "Flats/Maisonettes",
"studio": "Flats/Maisonettes",
"penthouse": "Flats/Maisonettes",
"cottage": "Other",
"chalet": "Other",
"farm_house": "Detached",
"country house": "Detached",
"village house": "Detached",
}
CHANNELS = [
{"channel": "BUY", "transactionType": "BUY", "sortType": "2"},
]

380
finder/homecouk.py Normal file
View file

@ -0,0 +1,380 @@
import json
import logging
import os
import random
import re
import time
from urllib.parse import unquote
from curl_cffi.requests import Session
from curl_cffi.requests.errors import RequestsError
from constants import (
DELAY_BETWEEN_PAGES,
HOMECOUK_API_BASE,
HOMECOUK_BASE,
HOMECOUK_PER_PAGE,
MAX_BEDROOMS,
PROPERTY_TYPE_MAP,
RETRY_BASE_DELAY,
)
from spatial import PostcodeSpatialIndex
from transform import normalize_postcode, normalize_sub_type, validate_floor_area
log = logging.getLogger("homecouk")
class CookiesExpiredError(Exception):
"""Raised when home.co.uk returns 403, indicating cookies need refresh."""
# Channel mapping: internal name → URL path segment
HOMECOUK_URL_SEGMENT = "for-sale"
def load_cookies() -> tuple[dict[str, str], str] | None:
"""Get home.co.uk cookies + user-agent.
Environment cookies are optional. When they are not present, bootstrap a
regular local session by visiting home.co.uk with curl_cffi's Chrome
impersonation and reusing the cookies set by the site.
"""
user_agent = os.environ.get(
"HOMECOUK_USER_AGENT",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/145.0.0.0 Safari/537.36",
)
env_cookies = {
name: value
for name, value in {
"cf_clearance": os.environ.get("HOMECOUK_CF_CLEARANCE", ""),
"homecouk_session": os.environ.get("HOMECOUK_SESSION", ""),
"XSRF-TOKEN": os.environ.get("HOMECOUK_XSRF_TOKEN", ""),
}.items()
if value
}
if env_cookies.get("homecouk_session"):
return env_cookies, user_agent
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"
),
}
)
for url in (HOMECOUK_BASE, f"{HOMECOUK_BASE}/for-sale/br1/"):
try:
response = session.get(url, timeout=30)
except RequestsError as exc:
log.warning("home.co.uk cookie bootstrap failed for %s: %s", url, exc)
continue
if response.status_code == 403:
raise CookiesExpiredError("home.co.uk returned HTTP 403 during bootstrap")
if response.status_code >= 400:
log.warning(
"home.co.uk cookie bootstrap got HTTP %d from %s",
response.status_code,
url,
)
cookies = session.cookies.get_dict()
if cookies.get("homecouk_session") and cookies.get("XSRF-TOKEN"):
log.info("home.co.uk local session bootstrapped")
return cookies, user_agent
log.warning("home.co.uk did not provide session cookies during bootstrap")
return None
def make_client(cookies: dict[str, str], user_agent: str) -> Session:
"""Create a curl_cffi Session configured for home.co.uk API calls.
Uses Chrome TLS impersonation so browser-derived cookies remain valid."""
session = Session(impersonate="chrome")
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.
xsrf = cookies.get("XSRF-TOKEN")
if xsrf:
session.headers["X-XSRF-TOKEN"] = unquote(xsrf)
for name, value in cookies.items():
session.cookies.set(name, value, domain="home.co.uk")
return session
def fetch_page(
client: Session, url: str, params: dict, max_retries: int = 3
) -> dict | None:
"""GET JSON with retries on 429/5xx. Returns None on permanent failure.
403 means cookies expired raises CookiesExpiredError immediately."""
for attempt in range(max_retries):
try:
resp = client.get(url, params=params, timeout=30)
if resp.status_code == 200:
try:
return resp.json()
except json.JSONDecodeError:
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")
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,
)
time.sleep(delay)
continue
log.error("HTTP %d from %s (non-retryable)", resp.status_code, url)
return None
except CookiesExpiredError:
raise
except RequestsError as e:
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,
)
time.sleep(delay)
log.error("All %d retries exhausted for %s", max_retries, url)
return None
def parse_floor_area(description: str | None) -> float | None:
"""Try to extract floor area from description text like '789 sq.ft.' or '73 sq.m.'."""
if not description:
return None
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*ft", description, re.IGNORECASE)
if m:
sqft = float(m.group(1).replace(",", ""))
return validate_floor_area(round(sqft * 0.092903, 1))
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*m", description, re.IGNORECASE)
if m:
return validate_floor_area(round(float(m.group(1).replace(",", "")), 1))
return None
def parse_tenure(prop: dict) -> str | None:
"""Extract tenure from home.co.uk property data.
Checks multiple sources in priority order:
1. Dedicated 'tenure' or 'tenure_type' field in the API response
2. Free-text search in the description for 'freehold' / 'leasehold'
3. Free-text search in features lists
home.co.uk aggregates listings from estate agents, so tenure is often
embedded in the description text rather than a structured field.
"""
# 1. Check dedicated tenure fields (in case the API adds them)
for key in ("tenure", "tenure_type", "tenureType"):
val = prop.get(key)
if val and isinstance(val, str):
lower = val.lower().strip()
if "leasehold" in lower:
return "Leasehold"
if "freehold" in lower:
return "Freehold"
# 2. Check description text — estate agents often include tenure here
description = prop.get("description") or ""
if description:
lower_desc = description.lower()
if re.search(r"\bleasehold\b", lower_desc):
return "Leasehold"
if re.search(r"\bfreehold\b", lower_desc):
# Matches "Freehold" and "Share of Freehold" (both = freehold ownership)
return "Freehold"
# 3. Check features / key_features lists if present
for key in ("features", "key_features", "keyFeatures"):
features = prop.get(key)
if features and isinstance(features, list):
for feat in features:
if not isinstance(feat, str):
continue
lower_feat = feat.lower()
if "leasehold" in lower_feat:
return "Leasehold"
if "freehold" in lower_feat:
return "Freehold"
return None
def map_property_type(raw_type: str | None) -> str:
"""Map home.co.uk property type to canonical type."""
if not raw_type:
return "Other"
canonical = PROPERTY_TYPE_MAP.get(raw_type)
if canonical:
return canonical
# 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
):
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"
log.debug("Unknown property type: %r — mapping to Other", raw_type)
return "Other"
def transform_property(
prop: dict,
pc_index: PostcodeSpatialIndex,
) -> dict | None:
"""Transform a raw home.co.uk property dict into our output schema."""
lat = prop.get("latitude")
lng = prop.get("longitude")
if lat is None or lng is None:
return None
# Validate coordinates are in England
if not (49 <= lat <= 56 and -7 <= lng <= 2):
log.debug("Coords outside England: lat=%.4f lng=%.4f — skipping", lat, lng)
return None
price = prop.get("price") or prop.get("latest_price")
if not price or int(price) <= 0:
return None
# Home.co.uk provides postcodes directly, but fall back to spatial index
postcode = prop.get("postcode")
if not postcode:
postcode = pc_index.nearest(lat, lng)
if not postcode:
log.debug("No postcode for property at %.4f, %.4f — skipping", lat, lng)
return None
raw_beds = prop.get("bedrooms", 0) or 0
raw_baths = prop.get("bathrooms", 0) or 0
bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0
bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
log.warning(
"home.co.uk %s: implausible beds=%d baths=%d (capped to 0)",
prop.get("listing_id") or prop.get("property_id") or "?",
raw_beds, raw_baths,
)
listing_type = prop.get("listing_property_type") or prop.get("property_type") or ""
address = prop.get("display_address") or prop.get("address") or ""
# Derive price qualifier from reduction info
price_qualifier = ""
if prop.get("is_reduced"):
pct = prop.get("reduction_percent", 0)
if pct:
price_qualifier = f"Reduced by {pct}%"
else:
price_qualifier = "Reduced"
listing_id = prop.get("listing_id") or prop.get("property_id") or ""
return {
"id": f"hk_{listing_id}", # prefix to avoid collision with Rightmove IDs
"Bedrooms": bedrooms,
"Bathrooms": bathrooms,
"Number of bedrooms & living rooms": bedrooms + bathrooms,
"lon": lng,
"lat": lat,
"Postcode": normalize_postcode(postcode),
"Address per Property Register": address,
"Leasehold/Freehold": parse_tenure(prop),
"Property type": map_property_type(listing_type),
"Property sub-type": normalize_sub_type(listing_type),
"price": int(price),
"price_frequency": "",
"Price qualifier": price_qualifier,
"Total floor area (sqm)": parse_floor_area(prop.get("description")),
"Listing URL": f"{HOMECOUK_BASE}/property/{listing_id}",
"Listing features": [], # not available from home.co.uk
"first_visible_date": prop.get("added_date") or "",
}
def search_outcode(
client: Session,
outcode: str,
pc_index: PostcodeSpatialIndex,
max_properties: int | None = None,
) -> list[dict]:
"""Paginate through sale search results for one outcode."""
url_segment = HOMECOUK_URL_SEGMENT
url = f"{HOMECOUK_API_BASE}/{url_segment}/{outcode.lower()}/"
properties = []
page = 1
while True:
params = {
"page": str(page),
"sort": "date_desc",
"per_page": str(HOMECOUK_PER_PAGE),
}
# Set referer to match the page URL pattern
client.headers["referer"] = (
f"https://home.co.uk/{url_segment}/{outcode.lower()}/"
f"?page={page}&sort=date_desc&per_page={HOMECOUK_PER_PAGE}"
)
data = fetch_page(client, url, params)
if not data:
break
raw_props = data.get("properties", [])
if not raw_props:
break
for prop in raw_props:
transformed = transform_property(prop, pc_index)
if transformed:
properties.append(transformed)
if max_properties is not None and len(properties) >= max_properties:
return properties
# Check pagination
pagination = data.get("pagination", {})
last_page = pagination.get("last_page", 1)
if page >= last_page:
break
page += 1
time.sleep(DELAY_BETWEEN_PAGES)
return properties

72
finder/http_client.py Normal file
View file

@ -0,0 +1,72 @@
import logging
import random
import time
import httpx
from fake_useragent import UserAgent
from constants import MAX_RETRIES, RETRY_BASE_DELAY
log = logging.getLogger("rightmove")
_ua = UserAgent(
browsers=["Chrome", "Edge"], os=["Windows", "Mac OS X"], min_version=120.0
)
def make_client() -> httpx.Client:
return httpx.Client(
timeout=30,
headers={"User-Agent": _ua.random, "Accept": "application/json"},
follow_redirects=True,
)
def fetch_with_retry(
client: httpx.Client, url: str, params: dict | None = None, on_403: bool = True
) -> dict | None:
"""GET JSON with retries on 429/5xx/connection errors.
Returns None on permanent failure. The on_403 argument is kept for
compatibility with older callers; 403 is now treated as non-retryable.
"""
for attempt in range(MAX_RETRIES):
try:
resp = client.get(url, params=params)
if resp.status_code == 200:
return resp.json()
if resp.status_code == 403 and on_403:
log.error("HTTP 403 from %s (forbidden)", url)
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,
)
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:
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,
)
time.sleep(delay)
log.error("All %d retries exhausted for %s", MAX_RETRIES, url)
return None

166
finder/main.py Normal file
View file

@ -0,0 +1,166 @@
import argparse
import logging
import os
import tempfile
import time
from pathlib import Path
from constants import DATA_DIR
SOURCE_CHOICES = ("rightmove", "homecouk", "zoopla", "all")
TEST_MAX_PROPERTIES_PER_SOURCE = 100
TEST_OUTCODES = (
"E1",
"N1",
"NW1",
"SE1",
"SW1",
"W1",
"WC1",
"BR1",
"CR0",
"TW1",
)
log = logging.getLogger("finder")
def configure_standalone_runtime() -> None:
"""Keep browser/cache/temp files on the project volume for local runs."""
runtime_dir = DATA_DIR / ".runtime"
cache_dir = runtime_dir / "cache"
temp_dir = runtime_dir / "tmp"
cache_dir.mkdir(parents=True, exist_ok=True)
temp_dir.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("XDG_CACHE_HOME", str(cache_dir))
os.environ.setdefault("TMPDIR", str(temp_dir))
tempfile.tempdir = str(temp_dir)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run a manual Greater London-ish property scrape."
)
parser.add_argument(
"--source",
choices=SOURCE_CHOICES,
default="all",
help="Portal to scrape. 'all' runs Rightmove, home.co.uk, and Zoopla.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=DATA_DIR,
help=f"Directory for parquet output. Defaults to {DATA_DIR}.",
)
parser.add_argument(
"--limit-outcodes",
type=int,
default=None,
help="Limit outcodes for a quick manual smoke test.",
)
parser.add_argument(
"--max-properties-per-source",
type=int,
default=None,
help="Stop each source after this many transformed listings.",
)
parser.add_argument(
"--test",
action="store_true",
help=(
"Run a small standalone smoke test: use likely London outcodes and "
f"fetch at most {TEST_MAX_PROPERTIES_PER_SOURCE} listings per source."
),
)
return parser.parse_args()
def configure_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
def selected_sources(source: str) -> list[str]:
if source == "all":
return ["rightmove", "homecouk", "zoopla"]
return [source]
def main() -> int:
args = parse_args()
configure_standalone_runtime()
configure_logging()
if args.limit_outcodes is not None and args.limit_outcodes < 1:
raise SystemExit("--limit-outcodes must be greater than zero")
if (
args.max_properties_per_source is not None
and args.max_properties_per_source < 1
):
raise SystemExit("--max-properties-per-source must be greater than zero")
output_dir = args.output_dir.expanduser().resolve()
if args.test and args.output_dir == DATA_DIR:
output_dir = (DATA_DIR / "test").expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
from scraper import (
build_postcode_coords,
build_postcode_index,
load_outcodes,
run_scrape,
)
outcodes = load_outcodes()
if args.test and args.limit_outcodes is None:
preferred = [outcode for outcode in TEST_OUTCODES if outcode in set(outcodes)]
if preferred:
outcodes = preferred
if args.limit_outcodes is not None:
outcodes = outcodes[: args.limit_outcodes]
if not outcodes:
raise SystemExit("No Greater London-ish outcodes loaded; nothing to scrape.")
sources = selected_sources(args.source)
max_properties_per_source = args.max_properties_per_source
if args.test and max_properties_per_source is None:
max_properties_per_source = TEST_MAX_PROPERTIES_PER_SOURCE
log.info(
"Starting sale scrape: source=%s outcodes=%d output_dir=%s test=%s",
args.source,
len(outcodes),
output_dir,
args.test,
)
started = time.monotonic()
pc_index = build_postcode_index()
pc_coords = build_postcode_coords() if "zoopla" in sources else None
result = run_scrape(
outcodes,
pc_index,
pc_coords=pc_coords,
sources=sources,
output_dir=output_dir,
max_properties_per_source=max_properties_per_source,
)
elapsed = time.monotonic() - started
log.info("Scrape finished in %.1fs", elapsed)
log.info("Result: %s", result)
if args.test and result.get("errors"):
raise SystemExit("Test scrape failed; see errors in the result above.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

12
finder/pyproject.toml Normal file
View file

@ -0,0 +1,12 @@
[project]
name = "finder"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"httpx",
"curl_cffi",
"polars",
"fake-useragent>=2.2.0",
"playwright>=1.58.0",
"camoufox>=0.4.11",
]

174
finder/rightmove.py Normal file
View file

@ -0,0 +1,174 @@
import logging
import time
import httpx
from constants import (
PAGE_SIZE,
DELAY_BETWEEN_PAGES,
SEARCH_URL,
TYPEAHEAD_URL,
)
from http_client import fetch_with_retry
from spatial import PostcodeSpatialIndex
from transform import transform_property
log = logging.getLogger("rightmove")
# Outcode ID cache (Rightmove typeahead → internal ID)
outcode_cache: dict[str, str] = {}
# Rightmove hard-caps pagination at index 1008 (42 pages × 24 results).
# Requesting index >= 1008 returns HTTP 400.
_MAX_INDEX = 1008
# Property type filters for splitting overcapped searches. Each sub-query
# gets its own 1008 cap, so we can recover listings beyond the unfiltered limit.
_PROPERTY_TYPES = [
"detached", "semi-detached", "terraced", "flat",
"bungalow", "park-home", "land",
]
def resolve_outcode_id(client: httpx.Client, outcode: str) -> str | None:
"""Look up Rightmove's internal ID for an outcode via typeahead API."""
if outcode in outcode_cache:
return outcode_cache[outcode]
data = fetch_with_retry(
client, TYPEAHEAD_URL, {"query": outcode, "limit": "10", "exclude": "STREET"}
)
if not data:
return None
for match in data.get("matches", []):
if match.get("type") == "OUTCODE" and match.get("displayName") == outcode:
rid = str(match["id"])
outcode_cache[outcode] = rid
return rid
log.debug("Outcode %s not found in typeahead results", outcode)
return None
def _paginate(
client: httpx.Client,
outcode_id: str,
outcode: str,
channel_cfg: dict,
pc_index: PostcodeSpatialIndex,
extra_params: dict | None = None,
max_properties: int | None = None,
) -> tuple[list[dict], int]:
"""Paginate through search results. Returns (properties, result_count)."""
properties = []
index = 0
result_count = 0
while True:
params = {
"useLocationIdentifier": "true",
"locationIdentifier": f"OUTCODE^{outcode_id}",
"index": str(index),
"sortType": channel_cfg["sortType"],
"channel": channel_cfg["channel"],
"transactionType": channel_cfg["transactionType"],
}
if extra_params:
params.update(extra_params)
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"],
)
break
raw_props = data.get("properties", [])
if not raw_props:
break
for prop in raw_props:
transformed = transform_property(prop, outcode, pc_index)
if transformed:
properties.append(transformed)
if max_properties is not None and len(properties) >= max_properties:
return properties, result_count
# Check if there are more pages
result_count_str = data.get("resultCount", "0")
result_count = int(result_count_str.replace(",", ""))
index += PAGE_SIZE
if index >= result_count:
break
time.sleep(DELAY_BETWEEN_PAGES)
return properties, result_count
def search_outcode(
client: httpx.Client,
outcode_id: str,
outcode: str,
channel_cfg: dict,
pc_index: PostcodeSpatialIndex,
max_properties: int | None = None,
) -> list[dict]:
"""Paginate through search results for one outcode+channel. Returns transformed properties.
When the unfiltered result count exceeds 1008 (Rightmove's hard pagination cap),
re-queries per property type to recover listings beyond the cap.
"""
properties, result_count = _paginate(
client, outcode_id, outcode, channel_cfg, pc_index, max_properties=max_properties
)
if max_properties is not None and len(properties) >= max_properties:
return properties[:max_properties]
if result_count <= _MAX_INDEX:
return properties
# Hit the 1008 cap — re-search per property type to get full coverage
ch = channel_cfg["channel"]
log.info(
"%s/%s: %d results exceed %d cap, splitting by property type",
outcode, ch, result_count, _MAX_INDEX,
)
all_by_id: dict[str, dict] = {p["id"]: p for p in properties}
for pt in _PROPERTY_TYPES:
pt_props, _ = _paginate(
client, outcode_id, outcode, channel_cfg, pc_index,
extra_params={"propertyTypes": pt},
max_properties=max_properties,
)
new = 0
for p in pt_props:
if p["id"] not in all_by_id:
all_by_id[p["id"]] = p
new += 1
if (
max_properties is not None
and len(all_by_id) >= max_properties
):
break
if new:
log.debug("%s/%s type=%s: +%d new properties", outcode, ch, pt, new)
if max_properties is not None and len(all_by_id) >= max_properties:
break
log.info(
"%s/%s: type split recovered %d%d properties",
outcode, ch, len(properties), len(all_by_id),
)
properties = list(all_by_id.values())
if max_properties is not None:
return properties[:max_properties]
return properties

580
finder/scraper.py Normal file
View file

@ -0,0 +1,580 @@
import logging
import re
import time
from pathlib import Path
from typing import Iterable
import polars as pl
from constants import (
ARCGIS_PATH,
CHANNELS,
DATA_DIR,
DELAY_BETWEEN_OUTCODES,
LONDON_OUTCODE_PREFIXES,
)
from homecouk import CookiesExpiredError
from homecouk import load_cookies as load_homecouk_cookies
from homecouk import make_client as make_homecouk_client
from homecouk import search_outcode as homecouk_search_outcode
from http_client import make_client
from rightmove import resolve_outcode_id
from rightmove import search_outcode as rightmove_search_outcode
from spatial import PostcodeSpatialIndex
from storage import write_parquet
from zoopla import TurnstileError
from zoopla import launch_browser as launch_zoopla_browser
from zoopla import search_outcode as zoopla_search_outcode
log = logging.getLogger("rightmove")
SOURCE_ORDER = ("rightmove", "homecouk", "zoopla")
SALE_CHANNEL = CHANNELS[0]
LONDON_AREAS = sorted({prefix.upper() for prefix in LONDON_OUTCODE_PREFIXES})
OUTCODE_RE = re.compile(r"^([A-Z]{1,2}\d[A-Z0-9]?)")
def _arcgis_columns() -> tuple[str, str]:
"""Return postcode and country column names for supported ARCGIS schemas."""
columns = set(pl.scan_parquet(ARCGIS_PATH).collect_schema().names())
if "pcd" in columns:
postcode_col = "pcd"
elif "pcds" in columns:
postcode_col = "pcds"
else:
raise ValueError(f"{ARCGIS_PATH} has no supported postcode column")
if "ctry" in columns:
country_col = "ctry"
elif "ctry25cd" in columns:
country_col = "ctry25cd"
else:
raise ValueError(f"{ARCGIS_PATH} has no supported country column")
return postcode_col, country_col
def _normalize_postcode(postcode: str) -> str:
compact = "".join(str(postcode).upper().split())
if len(compact) < 5:
return compact
return compact[:-3] + " " + compact[-3:]
def _londonish_postcode_expr(postcode_col: str) -> pl.Expr:
return (
pl.col(postcode_col)
.str.to_uppercase()
.str.extract(r"^([A-Z]{1,2})", 1)
.is_in(LONDON_AREAS)
)
def _outcode_area(outcode: str) -> str:
chars = []
for ch in outcode.upper():
if not ch.isalpha():
break
chars.append(ch)
return "".join(chars)
def is_londonish_outcode(outcode: str) -> bool:
normalized = outcode.upper()
return normalized in LONDON_AREAS or _outcode_area(normalized) in LONDON_AREAS
def _property_is_londonish(prop: dict) -> bool:
postcode = str(prop.get("Postcode") or "").upper().strip()
match = OUTCODE_RE.match(postcode)
return bool(match and is_londonish_outcode(match.group(1)))
def filter_londonish_outcodes(outcodes: Iterable[str]) -> list[str]:
return sorted(
{outcode.upper() for outcode in outcodes if is_londonish_outcode(outcode)}
)
def load_outcodes() -> list[str]:
"""Load England outcodes from ARCGIS and keep only Greater London-ish areas."""
log.info("Loading outcodes from %s", ARCGIS_PATH)
postcode_col, country_col = _arcgis_columns()
df = pl.read_parquet(ARCGIS_PATH, columns=[postcode_col, country_col])
england = df.filter(
(pl.col(country_col) == "E92000001")
& _londonish_postcode_expr(postcode_col)
)
outcodes = (
england.select(
pl.col(postcode_col)
.str.extract(r"^([A-Z]{1,2}\d[A-Z0-9]?)", 1)
.alias("outcode")
)
.drop_nulls()
.get_column("outcode")
.unique()
.sort()
.to_list()
)
londonish = filter_londonish_outcodes(outcodes)
log.info("Greater London-ish outcodes: %d", len(londonish))
return londonish
def build_postcode_index() -> PostcodeSpatialIndex:
"""Build spatial index from ARCGIS England postcodes."""
log.info("Building postcode spatial index from %s", ARCGIS_PATH)
postcode_col, country_col = _arcgis_columns()
df = pl.read_parquet(
ARCGIS_PATH, columns=[postcode_col, country_col, "lat", "long"]
)
england = df.filter(
(pl.col(country_col) == "E92000001")
& _londonish_postcode_expr(postcode_col)
).drop_nulls(
subset=["lat", "long"]
)
return PostcodeSpatialIndex(
england.get_column("lat").to_list(),
england.get_column("long").to_list(),
[
_normalize_postcode(pcd)
for pcd in england.get_column(postcode_col).to_list()
],
)
def build_postcode_coords() -> dict[str, tuple[float, float]]:
"""Build postcode -> (lat, lng) lookup from ARCGIS England postcodes."""
log.info("Building postcode coords lookup from %s", ARCGIS_PATH)
postcode_col, country_col = _arcgis_columns()
df = pl.read_parquet(
ARCGIS_PATH, columns=[postcode_col, country_col, "lat", "long"]
)
england = df.filter(
(pl.col(country_col) == "E92000001")
& _londonish_postcode_expr(postcode_col)
).drop_nulls(
subset=["lat", "long"]
)
coords: dict[str, tuple[float, float]] = {}
for pcd, lat, lng in zip(
england.get_column(postcode_col).to_list(),
england.get_column("lat").to_list(),
england.get_column("long").to_list(),
):
coords[_normalize_postcode(pcd)] = (lat, lng)
log.info("Postcode coords lookup: %d postcodes", len(coords))
return coords
def _source_names(sources: str | Iterable[str] | None) -> list[str]:
if sources is None:
return list(SOURCE_ORDER)
if isinstance(sources, str):
requested = [part.strip().lower() for part in sources.split(",")]
else:
requested = [str(source).strip().lower() for source in sources]
requested = [source for source in requested if source]
if "all" in requested:
return list(SOURCE_ORDER)
unknown = sorted(set(requested) - set(SOURCE_ORDER))
if unknown:
raise ValueError(f"Unknown source(s): {', '.join(unknown)}")
return [source for source in SOURCE_ORDER if source in requested]
def _dedup_key(prop: dict) -> tuple:
return (prop.get("Postcode", ""), prop.get("Bedrooms", 0), prop.get("price", 0))
def _merge_properties(source_results: dict[str, list[dict]]) -> tuple[list[dict], dict, int]:
merged: dict[str, dict] = {}
seen_keys: set[tuple] = set()
counts = {source: 0 for source in SOURCE_ORDER}
deduped = 0
for source in SOURCE_ORDER:
for prop in source_results.get(source, []):
prop_id = prop.get("id")
key = _dedup_key(prop)
if (prop_id is not None and prop_id in merged) or key in seen_keys:
deduped += 1
continue
storage_key = prop_id if prop_id is not None else f"{source}:{len(merged)}"
merged[storage_key] = prop
seen_keys.add(key)
counts[source] += 1
return list(merged.values()), counts, deduped
def _source_total(
results: dict[str, list[dict]],
source: str,
) -> int:
return len(results[source])
def _source_remaining(
results: dict[str, list[dict]],
source: str,
max_properties_per_source: int | None,
) -> int | None:
if max_properties_per_source is None:
return None
return max(max_properties_per_source - _source_total(results, source), 0)
def _store_properties(
results: dict[str, list[dict]],
source: str,
props: list[dict],
max_properties_per_source: int | None,
) -> int:
remaining = _source_remaining(results, source, max_properties_per_source)
if remaining == 0:
return 0
eligible = [prop for prop in props if _property_is_londonish(prop)]
dropped = len(props) - len(eligible)
if dropped:
log.debug(
"%s dropped %d properties outside the Greater London-ish postcode filter",
source,
dropped,
)
selected = eligible if remaining is None else eligible[:remaining]
results[source].extend(selected)
return len(selected)
def _record_error(
errors: list[str], source: str, outcode: str, exc: Exception
) -> None:
detail = " ".join(str(exc).split())
if len(detail) > 300:
detail = f"{detail[:300]}..."
message = f"{source} {outcode}: {detail}"
errors.append(message)
log.warning(message)
def _launch_zoopla_with_retries(attempts: int = 3):
last_error: Exception | None = None
for attempt in range(1, attempts + 1):
try:
return launch_zoopla_browser()
except Exception as exc:
last_error = exc
log.warning(
"Zoopla browser launch failed (%d/%d): %s",
attempt,
attempts,
exc,
)
time.sleep(5)
assert last_error is not None
raise last_error
def _new_homecouk_client():
cookie_data = load_homecouk_cookies()
if not cookie_data:
return None
return make_homecouk_client(*cookie_data)
def _scrape_rightmove(
outcodes: list[str],
pc_index: PostcodeSpatialIndex,
results: dict[str, list[dict]],
errors: list[str],
max_properties_per_source: int | None,
) -> None:
client = make_client()
try:
for outcode in outcodes:
if _source_remaining(results, "rightmove", max_properties_per_source) == 0:
log.info("Rightmove cap reached")
return
try:
outcode_id = resolve_outcode_id(client, outcode)
except Exception as exc:
_record_error(errors, "rightmove", outcode, exc)
time.sleep(DELAY_BETWEEN_OUTCODES)
continue
if not outcode_id:
log.debug("No Rightmove outcode ID for %s", outcode)
time.sleep(DELAY_BETWEEN_OUTCODES)
continue
remaining = _source_remaining(
results, "rightmove", max_properties_per_source
)
if remaining == 0:
log.info("Rightmove cap reached")
return
try:
props = rightmove_search_outcode(
client,
outcode_id,
outcode,
SALE_CHANNEL,
pc_index,
max_properties=remaining,
)
added = _store_properties(
results,
"rightmove",
props,
max_properties_per_source,
)
log.info("Rightmove %s: +%d", outcode, added)
except Exception as exc:
_record_error(errors, "rightmove", outcode, exc)
time.sleep(DELAY_BETWEEN_OUTCODES)
finally:
client.close()
def _scrape_homecouk(
outcodes: list[str],
pc_index: PostcodeSpatialIndex,
results: dict[str, list[dict]],
errors: list[str],
max_properties_per_source: int | None,
) -> None:
client = _new_homecouk_client()
if client is None:
log.warning("home.co.uk skipped: could not bootstrap a local session")
return
try:
for outcode in outcodes:
if _source_remaining(results, "homecouk", max_properties_per_source) == 0:
log.info("home.co.uk cap reached")
return
remaining = _source_remaining(
results, "homecouk", max_properties_per_source
)
if remaining == 0:
log.info("home.co.uk cap reached")
return
for attempt in range(2):
try:
props = homecouk_search_outcode(
client,
outcode,
pc_index,
max_properties=remaining,
)
added = _store_properties(
results,
"homecouk",
props,
max_properties_per_source,
)
log.info("home.co.uk %s: +%d", outcode, added)
break
except CookiesExpiredError as exc:
if attempt == 1:
_record_error(errors, "homecouk", outcode, exc)
break
log.warning(
"home.co.uk cookies expired at %s; refreshing local session",
outcode,
)
try:
client.close()
except Exception:
pass
client = _new_homecouk_client()
if client is None:
_record_error(
errors,
"homecouk",
outcode,
RuntimeError("could not refresh local session"),
)
return
except Exception as exc:
_record_error(errors, "homecouk", outcode, exc)
break
time.sleep(DELAY_BETWEEN_OUTCODES)
finally:
client.close()
def _scrape_zoopla(
outcodes: list[str],
pc_index: PostcodeSpatialIndex,
pc_coords: dict[str, tuple[float, float]],
results: dict[str, list[dict]],
errors: list[str],
max_properties_per_source: int | None,
) -> None:
try:
browser, page = _launch_zoopla_with_retries()
except Exception as exc:
errors.append(f"zoopla: browser launch failed: {exc}")
log.warning("Zoopla skipped: browser launch failed: %s", exc)
return
try:
for outcode in outcodes:
if _source_remaining(results, "zoopla", max_properties_per_source) == 0:
log.info("Zoopla cap reached")
return
remaining = _source_remaining(results, "zoopla", max_properties_per_source)
if remaining == 0:
log.info("Zoopla cap reached")
return
for attempt in range(2):
try:
props, _ = zoopla_search_outcode(
page,
outcode,
pc_index,
pc_coords,
max_properties=remaining,
)
added = _store_properties(
results,
"zoopla",
props,
max_properties_per_source,
)
log.info("Zoopla %s: +%d", outcode, added)
break
except Exception as exc:
if attempt == 1:
_record_error(errors, "zoopla", outcode, exc)
if isinstance(exc, TurnstileError):
return
break
log.warning("Zoopla %s failed; relaunching browser and retrying", outcode)
try:
browser.close()
except Exception:
pass
try:
browser, page = _launch_zoopla_with_retries()
except Exception as relaunch_exc:
_record_error(errors, "zoopla", outcode, relaunch_exc)
return
time.sleep(DELAY_BETWEEN_OUTCODES)
finally:
browser.close()
def run_scrape(
outcodes: list[str],
pc_index: PostcodeSpatialIndex,
pc_coords: dict[str, tuple[float, float]] | None = None,
sources: str | Iterable[str] | None = None,
output_dir: str | Path | None = None,
max_properties_per_source: int | None = None,
) -> dict:
"""Run one manual sale-listings scrape and write a parquet output."""
selected_sources = _source_names(sources)
selected_outcodes = filter_londonish_outcodes(outcodes)
if not selected_sources:
raise ValueError("No sources selected")
if not selected_outcodes:
raise ValueError("No Greater London-ish outcodes selected")
output_base = Path(output_dir) if output_dir is not None else DATA_DIR
output_base.mkdir(parents=True, exist_ok=True)
if "zoopla" in selected_sources and pc_coords is None:
pc_coords = build_postcode_coords()
errors: list[str] = []
results = {source: [] for source in SOURCE_ORDER}
started_at = time.time()
log.info(
"Starting manual sale scrape: %d outcodes, sources=%s, source_cap=%s",
len(selected_outcodes),
",".join(selected_sources),
max_properties_per_source,
)
if "rightmove" in selected_sources:
_scrape_rightmove(
selected_outcodes,
pc_index,
results,
errors,
max_properties_per_source,
)
if "homecouk" in selected_sources:
_scrape_homecouk(
selected_outcodes,
pc_index,
results,
errors,
max_properties_per_source,
)
if "zoopla" in selected_sources:
assert pc_coords is not None
_scrape_zoopla(
selected_outcodes,
pc_index,
pc_coords,
results,
errors,
max_properties_per_source,
)
merged, source_counts, deduped = _merge_properties(results)
output_path = output_base / "online_listings_buy.parquet"
write_parquet(merged, output_path)
counts = {
"total": len(merged),
"deduped": deduped,
"sources": source_counts,
}
log.info(
"Sale scrape complete: %d unique (rightmove:%d homecouk:%d zoopla:%d deduped:%d)",
len(merged),
source_counts["rightmove"],
source_counts["homecouk"],
source_counts["zoopla"],
deduped,
)
return {
"outcodes": len(selected_outcodes),
"sources": selected_sources,
"source_totals": {
source: _source_total(results, source) for source in selected_sources
},
"counts": counts,
"path": str(output_path),
"errors": errors,
"elapsed_seconds": round(time.time() - started_at, 3),
}

37
finder/spatial.py Normal file
View file

@ -0,0 +1,37 @@
import logging
import math
from collections import defaultdict
from constants import GRID_CELL_SIZE
log = logging.getLogger("rightmove")
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
)
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)
)
def nearest(self, lat: float, lng: float) -> str | None:
gx = int(math.floor(lng / GRID_CELL_SIZE))
gy = int(math.floor(lat / GRID_CELL_SIZE))
best_dist = float("inf")
best_pcd = None
for dx in range(-1, 2):
for dy in range(-1, 2):
for plat, plng, pcd in self.grid.get((gx + dx, gy + dy), []):
d = (plat - lat) ** 2 + (plng - lng) ** 2
if d < best_dist:
best_dist = d
best_pcd = pcd
return best_pcd

150
finder/storage.py Normal file
View file

@ -0,0 +1,150 @@
import logging
from datetime import datetime
from pathlib import Path
import polars as pl
from constants import MAX_BEDROOMS
from transform import map_property_type, normalize_postcode
log = logging.getLogger("rightmove")
def write_parquet(properties: list[dict], path: Path) -> None:
"""Write sale properties list to parquet with server-ready column names."""
if not properties:
log.warning("No properties to write to %s", path)
return
# Sanitize bedroom/bathroom counts — values above MAX_BEDROOMS are
# almost certainly prices or other numeric fields mis-parsed as bedrooms.
bad_count = 0
for p in properties:
for key in ("Bedrooms", "Bathrooms"):
val = p.get(key, 0) or 0
if val > MAX_BEDROOMS:
bad_count += 1
p[key] = None
# Recompute derived field after sanitization
beds = p.get("Bedrooms")
baths = p.get("Bathrooms")
if beds is None or baths is None:
p["Number of bedrooms & living rooms"] = None
else:
p["Number of bedrooms & living rooms"] = beds + baths
if bad_count:
log.warning(
"Sanitized %d properties with bedroom/bathroom counts > %d (set to null)",
bad_count,
MAX_BEDROOMS,
)
# Re-derive Property type from Property sub-type using current PROPERTY_TYPE_MAP.
# This retroactively fixes data scraped with older versions of the type map.
remapped = 0
for p in properties:
sub_type = p.get("Property sub-type", "")
if sub_type and sub_type != "Unknown":
new_type = map_property_type(sub_type)
if new_type != p.get("Property type"):
p["Property type"] = new_type
remapped += 1
if remapped:
log.info("Re-mapped %d property types from sub-types", remapped)
# Parse first_visible_date to datetime
listing_dates = []
for p in properties:
fvd = p.get("first_visible_date", "")
if fvd:
try:
dt = datetime.fromisoformat(fvd.replace("Z", "+00:00"))
# 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):
# Try additional date formats used by scraped listing sources.
parsed = None
stripped = fvd.strip()
lower = stripped.lower()
if lower == "today":
parsed = datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0
)
elif lower == "tomorrow":
from datetime import timedelta
parsed = (
datetime.now() + timedelta(days=1)
).replace(hour=0, minute=0, second=0, microsecond=0)
else:
for fmt in ("%d %B, %Y", "%d %B %Y"):
try:
parsed = datetime.strptime(stripped, fmt)
break
except ValueError:
continue
listing_dates.append(parsed)
else:
listing_dates.append(None)
# Zero prices indicate parsing failures or POA/auction listings — treat as null
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
listing_statuses = ["For sale"] * len(properties)
df = pl.DataFrame(
{
"Bedrooms": [p["Bedrooms"] for p in properties],
"Bathrooms": [p["Bathrooms"] for p in properties],
"Number of bedrooms & living rooms": [
p["Number of bedrooms & living rooms"] for p in properties
],
"lon": [p["lon"] for p in properties],
"lat": [p["lat"] for p in properties],
"Postcode": [normalize_postcode(p["Postcode"]) for p in properties],
"Address per Property Register": [
p["Address per Property Register"] for p in properties
],
"Leasehold/Freehold": [p["Leasehold/Freehold"] for p in properties],
"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],
"Listing URL": [p["Listing URL"] for p in properties],
"Listing features": [p["Listing features"] for p in properties],
"Listing date": listing_dates,
"Listing status": listing_statuses,
"Asking price": asking_prices,
},
schema={
"Bedrooms": pl.Int32,
"Bathrooms": pl.Int32,
"Number of bedrooms & living rooms": pl.Int32,
"lon": pl.Float64,
"lat": pl.Float64,
"Postcode": pl.Utf8,
"Address per Property Register": pl.Utf8,
"Leasehold/Freehold": pl.Utf8,
"Property type": pl.Utf8,
"Property sub-type": pl.Utf8,
"Price qualifier": pl.Utf8,
"Total floor area (sqm)": pl.Float64,
"Listing URL": pl.Utf8,
"Listing features": pl.List(pl.Utf8),
"Listing date": pl.Datetime("us"),
"Listing status": pl.Utf8,
"Asking price": pl.Int64,
},
)
df = df.with_columns(
(pl.col("Asking price") / pl.col("Total floor area (sqm)"))
.round(0)
.cast(pl.Int32, strict=False)
.alias("Asking price per sqm"),
)
df.write_parquet(path)
log.info("Wrote %d properties to %s", len(df), path)

219
finder/transform.py Normal file
View file

@ -0,0 +1,219 @@
import logging
import re
from constants import MAX_BEDROOMS, PROPERTY_TYPE_MAP, RIGHTMOVE_BASE
from spatial import PostcodeSpatialIndex
log = logging.getLogger("rightmove")
# Floor area bounds (sqm). Values outside this range are almost certainly
# data errors: sub-5 sqm catches garbled extractions (e.g., 0.1 sqm for a
# detached house), and >2000 sqm (~21,500 sq ft) exceeds even the largest
# UK mansions.
MIN_FLOOR_AREA_SQM = 5.0
MAX_FLOOR_AREA_SQM = 2000.0
def validate_floor_area(sqm: float | None) -> float | None:
"""Validate a floor area value. Returns None for nonsensical values.
Rejects values below MIN_FLOOR_AREA_SQM and above MAX_FLOOR_AREA_SQM,
which catches parsing errors where prices or other large numbers are
mistakenly extracted as floor area from free-text descriptions or DOM text.
"""
if sqm is None:
return None
if sqm < MIN_FLOOR_AREA_SQM or sqm > MAX_FLOOR_AREA_SQM:
return None
return sqm
def parse_display_size(display_size: str | None) -> float | None:
"""Parse displaySize like '499 sq. ft.' or '4,124 sq. ft.' to sqm."""
if not display_size:
return None
# Try sq. ft. first
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*ft", display_size, re.IGNORECASE)
if m:
sqft = float(m.group(1).replace(",", ""))
return validate_floor_area(round(sqft * 0.092903, 1))
# Try sq. m.
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*m", display_size, re.IGNORECASE)
if m:
return validate_floor_area(round(float(m.group(1).replace(",", "")), 1))
return None
def normalize_sub_type(sub_type: str | None) -> str:
"""Normalize property sub-type for consistent storage.
Fixes delimiter inconsistencies (underscores/hyphens spaces) from
home.co.uk and truncates Zoopla description fragments that were
accidentally captured as sub-types.
"""
if not sub_type:
return "Unknown"
cleaned = sub_type.replace("_", " ").strip()
# Description fragments captured as sub-types are much longer than any
# real property type name (longest canonical is ~25 chars)
if len(cleaned) > 40:
return "Unknown"
# Collapse multiple spaces
cleaned = re.sub(r"\s+", " ", cleaned)
return cleaned.title()
def map_property_type(sub_type: str | None) -> str:
"""Map propertySubType to canonical type."""
if not sub_type:
return "Other"
canonical = PROPERTY_TYPE_MAP.get(sub_type)
if canonical:
return canonical
# Try title-case variant (e.g., "country house" → "Country House")
canonical = PROPERTY_TYPE_MAP.get(sub_type.title())
if canonical:
return canonical
# Try lowercase variant (e.g., "Townhouse" → "townhouse")
canonical = PROPERTY_TYPE_MAP.get(sub_type.lower())
if canonical:
return canonical
# Normalize delimiters (underscores/hyphens → spaces) and try again
normalized = re.sub(r"[-_]+", " ", sub_type).strip().title()
canonical = PROPERTY_TYPE_MAP.get(normalized)
if canonical:
return canonical
# Keyword fallback for compound types not in the map
lower = sub_type.lower()
if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower:
return "Flats/Maisonettes"
if "semi" in lower and "detach" in lower:
return "Semi-Detached"
if "detach" in lower:
return "Detached"
if "terrace" in lower or "mews" in lower:
return "Terraced"
if "house" in lower or "cottage" in lower:
return "Detached"
log.warning("Unknown propertySubType: %r — mapping to Other", sub_type)
return "Other"
def extract_tenure(tenure_obj: dict | None) -> str | None:
"""Extract tenure string from tenure object."""
if not tenure_obj:
return None
tt = tenure_obj.get("tenureType", "")
if tt == "FREEHOLD":
return "Freehold"
if tt == "LEASEHOLD":
return "Leasehold"
return None
def fix_coords(lat: float, lng: float) -> tuple[float, float]:
"""Swap lat/lng if they look reversed. England: lat ~4956, lng ~-72."""
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,
)
return lng, lat
log.warning(
"Coords outside England bounds even after swap attempt: lat=%.4f lng=%.4f",
lat,
lng,
)
return lat, lng
def normalize_postcode(postcode: str) -> str:
"""Ensure UK postcode has exactly one space before the 3-char incode.
E.g., 'SW1A1AA' 'SW1A 1AA', 'N4 2HA' 'N4 2HA', 'E1 4AB' unchanged."""
# Strip all whitespace then re-insert the single canonical space
compact = re.sub(r"\s+", "", postcode).upper()
if len(compact) < 5:
return compact
return compact[:-3] + " " + compact[-3:]
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:
return None
raw_lat = loc.get("latitude")
raw_lng = loc.get("longitude")
if raw_lat is None or raw_lng is None:
return None
lat, lng = fix_coords(raw_lat, raw_lng)
price_obj = prop.get("price", {})
amount = price_obj.get("amount")
if not amount:
return None
price = int(amount)
if price <= 0:
return None
display_prices = price_obj.get("displayPrices", [])
price_qualifier = (
display_prices[0].get("displayPriceQualifier", "") if display_prices else ""
)
# POA / Auction listings have unreliable prices — treat as no price
pq_lower = price_qualifier.lower()
if "poa" in pq_lower or "auction" in pq_lower:
return None
sub_type = prop.get("propertySubType", "")
raw_beds = prop.get("bedrooms", 0) or 0
raw_baths = prop.get("bathrooms", 0) or 0
bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0
bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
log.warning(
"Rightmove %s: implausible beds=%d baths=%d (capped to 0)",
prop.get("id", "?"), raw_beds, raw_baths,
)
key_features = [
kf.get("description", "")
for kf in prop.get("keyFeatures", [])
if kf.get("description")
]
postcode = pc_index.nearest(lat, lng)
if not postcode:
log.debug("No England postcode for property at %.4f, %.4f — skipping", lat, lng)
return None
return {
"id": prop.get("id"),
"Bedrooms": bedrooms,
"Bathrooms": bathrooms,
"Number of bedrooms & living rooms": bedrooms + bathrooms,
"lon": lng,
"lat": lat,
"Postcode": postcode,
"Address per Property Register": prop.get("displayAddress", ""),
"Leasehold/Freehold": extract_tenure(prop.get("tenure")),
"Property type": map_property_type(sub_type),
"Property sub-type": normalize_sub_type(sub_type),
"price": price,
"price_frequency": "",
"Price qualifier": price_qualifier,
"Total floor area (sqm)": parse_display_size(prop.get("displaySize")),
"Listing URL": RIGHTMOVE_BASE + prop.get("propertyUrl", ""),
"Listing features": key_features,
"first_visible_date": prop.get("firstVisibleDate", ""),
}

823
finder/uv.lock generated Normal file
View file

@ -0,0 +1,823 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
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 = "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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ 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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "curl-cffi"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" },
{ url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" },
{ url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" },
{ url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" },
{ url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" },
{ url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" },
{ url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" },
{ url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" },
{ url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" },
{ 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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/43/948d10bf42735709edb5ae51e23297d034086f17fc7279fef385a7acb473/fake_useragent-2.2.0.tar.gz", hash = "sha256:4e6ab6571e40cc086d788523cf9e018f618d07f9050f822ff409a4dfe17c16b2", size = 158898, upload-time = "2025-04-14T15:32:19.238Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695, upload-time = "2025-04-14T15:32:17.732Z" },
]
[[package]]
name = "finder"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "camoufox" },
{ name = "curl-cffi" },
{ name = "fake-useragent" },
{ name = "httpx" },
{ name = "playwright" },
{ name = "polars" },
]
[package.metadata]
requires-dist = [
{ name = "camoufox", specifier = ">=0.4.11" },
{ name = "curl-cffi" },
{ name = "fake-useragent", specifier = ">=2.2.0" },
{ name = "httpx" },
{ name = "playwright", specifier = ">=1.58.0" },
{ name = "polars" },
]
[[package]]
name = "greenlet"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" },
{ url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" },
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
{ url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" },
{ url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[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 = "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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },
{ url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },
{ url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },
{ url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" },
{ url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" },
{ 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 = "polars"
version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "polars-runtime-32" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/b8/3a6a5b85e34af7936620f331f04f8bed235625439f5bd80832f968648618/polars-1.39.0.tar.gz", hash = "sha256:e63a25fb7682ae660e36067915a7c71a653b17f82308a8eb67a190a80daf0710", size = 728783, upload-time = "2026-03-12T14:24:47.876Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/f8/fad8470d9701c1b208cc24919a661efdf565373e77e7d06400642a759285/polars-1.39.0-py3-none-any.whl", hash = "sha256:4d1198b41bc47561673d9f54d0f595125202a3f53e3502821802958d3e60efe9", size = 823938, upload-time = "2026-03-12T14:22:37.78Z" },
]
[[package]]
name = "polars-runtime-32"
version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/1e/fce83ad77bfed1bf4a83f74dde19e2572c32fc040e93bd98d161e3950eaf/polars_runtime_32-1.39.0.tar.gz", hash = "sha256:f5aabed8c7318fcad5173e83bee385445f54b5f8c83b1ec9eab78bdffa293141", size = 2870686, upload-time = "2026-03-12T14:24:49.41Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/6d/143b552baa9e859ae266f087f3ec0aeb29e5acc39e1f49c1a64023cee469/polars_runtime_32-1.39.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4a4bc06ca97238d963979e3f888fbb500ee607f03cefe43a9062381e259503e2", size = 45299222, upload-time = "2026-03-12T14:22:40.821Z" },
{ url = "https://files.pythonhosted.org/packages/97/ec/eb4e57eedfb97019f951b298fa4cd232a50db65aa6702c735b6f272a0fa0/polars_runtime_32-1.39.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9914b9e168634bc21d07ee03b8fa92d0aaa8ac7b2bb1c9e2f1f78622aa1b8f4", size = 40863978, upload-time = "2026-03-12T14:22:45.16Z" },
{ url = "https://files.pythonhosted.org/packages/5f/b7/28fa0345586f7c449dd27d687c32a10dcea470ebc5a978d7fc47e463b298/polars_runtime_32-1.39.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ded58f1c28e17ecbff8625cb1ad93016761260348acb79b1a4cd077970e89e5", size = 43231627, upload-time = "2026-03-12T14:22:49.464Z" },
{ url = "https://files.pythonhosted.org/packages/cf/60/c0d0b6720437685223457242a79f6bba443485ca85928645786479ebed86/polars_runtime_32-1.39.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b82c872b25ef6628462f90f1b6b3950779aee36889e83b3693d0a69684d3d86a", size = 46899324, upload-time = "2026-03-12T14:22:54.364Z" },
{ url = "https://files.pythonhosted.org/packages/73/98/53ad9c8a6f151e098e4f65c5146f9e538f1ba148feb5289fd2a4c5e2d764/polars_runtime_32-1.39.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4a0e9d6b56362f3ba1a33d0538ae14c9b9a8e0fb835f86abfc82fa7b2c7d89c9", size = 43389283, upload-time = "2026-03-12T14:22:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/74/a2/21f77d6e588ee7c8e7f6232d135538690411de2ea6415d8bbe9b8d684f37/polars_runtime_32-1.39.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0daea3919661ba672b00bd01b5547cd29bb6414732457abb72cbc75103cf3c90", size = 46509946, upload-time = "2026-03-12T14:23:05.215Z" },
{ url = "https://files.pythonhosted.org/packages/24/a3/37a56ad2d931c857b892b22760b9bf9a53f681d9ccf27741cf6dd8489320/polars_runtime_32-1.39.0-cp310-abi3-win_amd64.whl", hash = "sha256:d6e9d1cf264aacfe5bf03241c04ef435d0f9cfec3fbe079acc3a7328a737961a", size = 47012669, upload-time = "2026-03-12T14:23:11.134Z" },
{ url = "https://files.pythonhosted.org/packages/b3/eb/936f5eeae196e8c8aaabe5f7d98891be8a5bbc741d50ce5c60f55575ad29/polars_runtime_32-1.39.0-cp310-abi3-win_arm64.whl", hash = "sha256:d69abde5f148566860bbe910010847bd7791e72f7c8063a4d2c462246a33a72a", size = 41885761, upload-time = "2026-03-12T14:23:16.773Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
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 = "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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
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" },
]

784
finder/zoopla.py Normal file
View file

@ -0,0 +1,784 @@
"""Zoopla (zoopla.co.uk) scraper — sale 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.
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. Navigates directly to the sale search URL
2. Extracts listing data from the rendered DOM
3. 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, MAX_BEDROOMS, PROPERTY_TYPE_MAP, ZOOPLA_BASE
from spatial import PostcodeSpatialIndex
from transform import normalize_sub_type, validate_floor_area
log = logging.getLogger("zoopla")
class TurnstileError(Exception):
"""Raised when Cloudflare Turnstile challenge cannot be passed."""
class _ManagedCamoufoxBrowser:
def __init__(self, context_manager, browser):
self._context_manager = context_manager
self._browser = browser
self._closed = False
def close(self) -> None:
if self._closed:
return
self._closed = True
try:
self._browser.close()
finally:
self._context_manager.__exit__(None, None, None)
def __getattr__(self, name):
return getattr(self._browser, name)
# Maximum search result pages to scrape per outcode (25 listings/page)
MAX_PAGES_PER_OUTCODE = 40
# JavaScript to extract listings from the rendered DOM.
# Uses data-testid attributes as primary selectors (stable across deployments),
# then falls back to href-based link matching with parent-walking.
_EXTRACT_LISTINGS_JS = r"""() => {
const seen = new Set();
const results = [];
// Strategy 1: Use data-testid selectors (post-2025 redesign)
const listingCards = document.querySelectorAll(
'[data-testid="regular-listings"] > div, [data-testid="search-content"] li'
);
for (const card of listingCards) {
const link = card.querySelector(
'a[href*="/for-sale/details/"], a[href*="/new-homes/details/"]'
);
if (!link) continue;
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);
const text = card.innerText || '';
// Try data-testid price element first, then regex
const priceEl = card.querySelector('[data-testid="listing-price"]');
const priceText = priceEl ? priceEl.innerText : text;
const priceMatch = priceText.match(/\u00a3([\d,]+)/);
// Try address element first, then regex
const addressEl = card.querySelector('address');
let address = addressEl ? addressEl.innerText.trim() : '';
if (!address) {
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
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;
}
}
}
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 tenure = '';
if (/leasehold/i.test(text)) tenure = 'Leasehold';
else if (/freehold/i.test(text)) tenure = 'Freehold';
// Extract property type (e.g., "2 bed flat for sale" "flat")
let property_type = '';
const ptMatch = text.match(/\d+\s*(?:beds?|bedrooms?)\s+([\w\s-]+?)\s+for\s+sale/i);
if (ptMatch) property_type = ptMatch[1].trim();
else if (/\bstudio\s*(?:flat|apartment)?\s+for\s+sale/i.test(text)) property_type = 'Studio';
// Keyword fallback when regex doesn't match current DOM format
if (!property_type) {
const lower = text.toLowerCase();
if (/\bstudio\b/.test(lower)) property_type = 'Studio';
else if (/\bpenthouse\b/.test(lower)) property_type = 'Penthouse';
else if (/\bmaisonette\b/.test(lower)) property_type = 'Maisonette';
else if (/\bapartment\b/.test(lower)) property_type = 'Apartment';
else if (/\bflat\b/.test(lower)) property_type = 'Flat';
else if (/\bsemi[- ]?detached\b/.test(lower)) property_type = 'Semi-Detached';
else if (/\bdetached\b/.test(lower)) property_type = 'Detached';
else if (/\bterraced?\b/.test(lower)) property_type = 'Terraced';
else if (/\bbungalow\b/.test(lower)) property_type = 'Bungalow';
else if (/\bcottage\b/.test(lower)) property_type = 'Cottage';
else if (/\bhouse\b/.test(lower)) property_type = 'House';
}
results.push({
id, url: href.replace(window.location.origin, ''),
price: priceMatch ? parseInt(priceMatch[1].replace(/,/g, '')) : null,
price_text: priceText.trim(),
beds: bedsMatch && parseInt(bedsMatch[1]) <= 20 ? parseInt(bedsMatch[1]) : null,
baths: bathsMatch && parseInt(bathsMatch[1]) <= 20 ? parseInt(bathsMatch[1]) : null,
receptions: recMatch && parseInt(recMatch[1]) <= 20 ? parseInt(recMatch[1]) : null,
floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
address, tenure, property_type,
});
}
// Strategy 2: Fall back to href-based link matching with parent-walking
if (results.length === 0) {
const links = Array.from(document.querySelectorAll(
'a[href*="/for-sale/details/"], a[href*="/new-homes/details/"]'
));
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);
let card = link;
for (let j = 0; j < 15; j++) {
card = card.parentElement;
if (!card) break;
const t = card.innerText || '';
if (t.includes('\u00a3') && (t.includes('bed') || t.includes('Bath') || t.includes('sq ft'))) {
break;
}
}
if (!card) continue;
const text = card.innerText || '';
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
const priceEl2 = card.querySelector('[data-testid="listing-price"]');
const priceText2 = priceEl2 ? priceEl2.innerText : text;
const priceMatch = priceText2.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 (/leasehold/i.test(text)) tenure = 'Leasehold';
else if (/freehold/i.test(text)) tenure = 'Freehold';
// Extract property type
let property_type = '';
const ptMatch2 = text.match(/\d+\s*(?:beds?|bedrooms?)\s+([\w\s-]+?)\s+for\s+sale/i);
if (ptMatch2) property_type = ptMatch2[1].trim();
else if (/\bstudio\s*(?:flat|apartment)?\s+for\s+sale/i.test(text)) property_type = 'Studio';
// Keyword fallback when regex doesn't match current DOM format
if (!property_type) {
const lower = text.toLowerCase();
if (/\bstudio\b/.test(lower)) property_type = 'Studio';
else if (/\bpenthouse\b/.test(lower)) property_type = 'Penthouse';
else if (/\bmaisonette\b/.test(lower)) property_type = 'Maisonette';
else if (/\bapartment\b/.test(lower)) property_type = 'Apartment';
else if (/\bflat\b/.test(lower)) property_type = 'Flat';
else if (/\bsemi[- ]?detached\b/.test(lower)) property_type = 'Semi-Detached';
else if (/\bdetached\b/.test(lower)) property_type = 'Detached';
else if (/\bterraced?\b/.test(lower)) property_type = 'Terraced';
else if (/\bbungalow\b/.test(lower)) property_type = 'Bungalow';
else if (/\bcottage\b/.test(lower)) property_type = 'Cottage';
else if (/\bhouse\b/.test(lower)) property_type = 'House';
}
results.push({
id, url: href.replace(window.location.origin, ''),
price: priceMatch ? parseInt(priceMatch[1].replace(/,/g, '')) : null,
price_text: priceText2.trim(),
beds: bedsMatch && parseInt(bedsMatch[1]) <= 20 ? parseInt(bedsMatch[1]) : null,
baths: bathsMatch && parseInt(bathsMatch[1]) <= 20 ? parseInt(bathsMatch[1]) : null,
receptions: recMatch && parseInt(recMatch[1]) <= 20 ? parseInt(recMatch[1]) : null,
floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
address, tenure, property_type,
});
}
}
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 two minutes.
Caller must close browser when done."""
from camoufox.pkgman import camoufox_path
# Standalone local runs should not require the old container image to have
# pre-fetched Camoufox.
camoufox_path(download_if_missing=True)
from camoufox.sync_api import Camoufox
log.info("Launching Camoufox browser for Zoopla...")
camoufox = Camoufox(headless=True)
raw_browser = camoufox.__enter__()
browser = _ManagedCamoufoxBrowser(camoufox, raw_browser)
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(40):
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 120s")
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(40):
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 after 120s")
# ---------------------------------------------------------------------------
# Search navigation
# ---------------------------------------------------------------------------
def _wait_for_listing_content(page) -> None:
"""Wait for rendered listing cards to contain usable text."""
try:
page.wait_for_function(
"""() => {
const cards = document.querySelectorAll(
'[data-testid="regular-listings"] > div'
);
if (cards.length === 0) return false;
for (const card of cards) {
const t = card.innerText || '';
if (t.includes('\\u00a3') && t.length > 50) return true;
}
return false;
}""",
timeout=8000,
)
except Exception:
time.sleep(1.5)
def _navigate_search(page, outcode: str) -> bool:
"""Navigate directly to sale search results for an outcode.
Returns True if results were found, False if no results or navigation failed.
Raises TurnstileError if Cloudflare blocks us."""
url = (
f"{ZOOPLA_BASE}/for-sale/property/{outcode.lower()}/"
f"?q={outcode}&search_source=home"
)
try:
page.goto(url, wait_until="domcontentloaded", timeout=30000)
except Exception as exc:
log.debug("Zoopla direct navigation failed for %s: %s", outcode, exc)
return False
_ensure_not_challenged(page)
# Dismiss cookie consent (may reappear after navigation)
try:
page.evaluate(_DISMISS_COOKIES_JS)
except Exception:
pass
try:
page.wait_for_selector(
'[data-testid="regular-listings"], a[href*="/for-sale/details/"], a[href*="/new-homes/details/"]',
timeout=10000,
)
except Exception:
if not page.query_selector('a[href*="/details/"]'):
return False
_wait_for_listing_content(page)
return True
def _get_result_count(page) -> int:
"""Extract the total results count from the page.
Tries __ZAD_TARGETING__ JSON first (most reliable), then body text regex
matching both "N results" and "N properties" patterns."""
try:
# Try the ZAD targeting JSON script tag first
count = page.evaluate("""() => {
const s = document.querySelector('#__ZAD_TARGETING__');
if (s) {
try {
const d = JSON.parse(s.textContent);
if (d.search_results_count != null) return d.search_results_count;
} catch(e) {}
}
return null;
}""")
if count is not None and count > 0:
return count
except Exception:
pass
try:
body = page.inner_text("body")
match = re.search(r"([\d,]+)\s+(?:results?|properties)", body)
if match:
return int(match.group(1).replace(",", ""))
except Exception:
pass
return 0
# ---------------------------------------------------------------------------
# Extraction and pagination
# ---------------------------------------------------------------------------
_first_extraction_logged = False
def _extract_listings(page) -> list[dict]:
"""Extract listing data from the current search results page DOM."""
global _first_extraction_logged
try:
listings = page.evaluate(_EXTRACT_LISTINGS_JS)
# Log diagnostic info on the very first extraction attempt
if not _first_extraction_logged:
_first_extraction_logged = True
try:
diag = page.evaluate("""() => {
const details = document.querySelectorAll('a[href*="/details/"]');
const testids = document.querySelectorAll('[data-testid]');
const testidNames = [...new Set([...testids].map(e => e.dataset.testid))];
return {
url: location.href,
title: document.title,
detailLinks: details.length,
testids: testidNames.slice(0, 30),
bodySnippet: document.body?.innerText?.slice(0, 500) || '',
};
}""")
log.info(
"Zoopla first-page diagnostic: url=%s title=%s detailLinks=%d "
"testids=%s bodySnippet=%.200s",
diag.get("url"), diag.get("title"), diag.get("detailLinks", 0),
diag.get("testids", []), diag.get("bodySnippet", ""),
)
except Exception:
pass
log.info("Zoopla first extraction: %d listings found", len(listings))
return listings
except Exception as e:
log.warning("Failed to extract listings from DOM: %s", e)
return []
def _paginate(
page,
total_results: int,
max_properties: int | None = None,
) -> 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)
if max_properties is not None and len(all_listings) >= max_properties:
return all_listings[:max_properties]
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)
_ensure_not_challenged(page)
_wait_for_listing_content(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
if max_properties is not None and len(all_listings) >= max_properties:
return all_listings[:max_properties]
if new_count == 0:
break # No new listings on this page
page_num += 1
return all_listings
# ---------------------------------------------------------------------------
# Property transformation
# ---------------------------------------------------------------------------
# Cached outcode → (postcode, lat, lng) lookups to avoid repeated O(n) scans
# over 2.26M postcodes. Populated lazily on first lookup per outcode.
_outcode_coords_cache: dict[str, tuple[str, float, float] | None] = {}
def _resolve_outcode_coords(
outcode: str, pc_coords: dict[str, tuple[float, float]]
) -> tuple[str, float, float] | None:
"""Find first postcode + coords for an outcode. Result is cached."""
if outcode in _outcode_coords_cache:
return _outcode_coords_cache[outcode]
prefix = outcode + " "
for pcd, (lat, lng) in pc_coords.items():
if pcd.startswith(prefix) or (
len(outcode) >= 4
and pcd.startswith(outcode)
and len(pcd) > len(outcode)
):
_outcode_coords_cache[outcode] = (pcd, lat, lng)
return (pcd, lat, lng)
_outcode_coords_cache[outcode] = None
return None
def _extract_postcode(text: str) -> str | None:
"""Extract a full UK postcode from text like 'Dollar Bay Place, Canary Wharf E14 9SS'.
Normalizes to include a space before the 3-char incode."""
match = re.search(r"([A-Z]{1,2}\d[A-Z0-9]?\s*\d[A-Z]{2})", text, re.IGNORECASE)
if match:
raw = match.group(1).upper().strip()
# Ensure space before incode (last 3 chars): "SW1A1AA" → "SW1A 1AA"
if " " not in raw and len(raw) >= 5:
return raw[:-3] + " " + raw[-3:]
return raw
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"
# Exact match (handles Rightmove-style capitalised values)
canonical = PROPERTY_TYPE_MAP.get(raw_type)
if canonical:
return canonical
# Title-case match (handles regex-extracted lowercase like "town house" → "Town House")
canonical = PROPERTY_TYPE_MAP.get(raw_type.title())
if canonical:
return canonical
# Lowercase match (e.g., "Townhouse" → "townhouse")
canonical = PROPERTY_TYPE_MAP.get(raw_type.lower())
if canonical:
return canonical
# Normalize delimiters (underscores/hyphens → spaces) and try again
normalized = re.sub(r"[-_]+", " ", raw_type).strip().title()
canonical = PROPERTY_TYPE_MAP.get(normalized)
if canonical:
return canonical
# Keyword fallback
lower = raw_type.lower()
if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower or "penthouse" in lower:
return "Flats/Maisonettes"
if "semi" in lower and "detach" in lower:
return "Semi-Detached"
if "detach" in lower:
return "Detached"
if "terrace" in lower or "mews" in lower:
return "Terraced"
if "house" in lower:
return "Detached"
return "Other"
def transform_property(
raw: dict,
pc_index: PostcodeSpatialIndex,
pc_coords: dict[str, tuple[float, float]],
search_outcode: str | None = None,
) -> 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 or int(price) <= 0:
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 from address text
addr_outcode = _extract_outcode(address)
if addr_outcode:
result = _resolve_outcode_coords(addr_outcode, pc_coords)
if result:
postcode, lat, lng = result
# Final fallback: use the outcode we know we're searching
if lat is None and search_outcode:
result = _resolve_outcode_coords(search_outcode, pc_coords)
if result:
postcode, lat, lng = result
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
raw_beds = raw.get("beds") or 0
raw_baths = raw.get("baths") or 0
bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0
bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
log.warning(
"Zoopla %s: implausible beds=%d baths=%d (capped to 0)",
raw.get("id", "?"), raw_beds, raw_baths,
)
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 = validate_floor_area(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": _map_property_type(raw.get("property_type")),
"Property sub-type": normalize_sub_type(raw.get("property_type")),
"price": int(price),
"price_frequency": "",
"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,
pc_index: PostcodeSpatialIndex,
pc_coords: dict[str, tuple[float, float]],
max_properties: int | None = None,
) -> tuple[list[dict], str | None]:
"""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.
Returns (properties, search_url).
Raises TurnstileError if Cloudflare blocks us mid-session.
"""
if not _navigate_search(page, outcode):
return [], None
total_results = _get_result_count(page)
# Always try extraction even if result count is 0 — the count regex may
# not match Zoopla's current text format, but listings may still be in DOM
raw_listings = _paginate(
page,
max(total_results, 25),
max_properties=max_properties,
)
if not raw_listings:
if total_results > 0:
log.debug(
"Zoopla %s %s: page claims %d results but extraction found 0 — "
"DOM selectors may need updating",
outcode, "BUY", total_results,
)
return [], None
properties = []
dropped = 0
for raw in raw_listings:
transformed = transform_property(raw, pc_index, pc_coords, search_outcode=outcode)
if transformed:
properties.append(transformed)
else:
dropped += 1
if dropped and not properties:
# Log a sample raw listing to diagnose which fields are missing
sample = raw_listings[0] if raw_listings else {}
log.debug(
"Zoopla %s %s: extracted %d raw listings but all %d dropped in transform "
"(no price/postcode/coords). Sample raw: price=%s address=%r",
outcode, "BUY", len(raw_listings), dropped,
sample.get("price"), sample.get("address", ""),
)
elif dropped > len(raw_listings) // 2:
log.debug(
"Zoopla %s %s: %d/%d listings dropped in transform",
outcode, "BUY", dropped, len(raw_listings),
)
return properties, page.url

View file

@ -20,6 +20,7 @@
"@protomaps/basemaps": "^5.7.2",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@sentry/react": "^10.53.1",
"@types/supercluster": "^7.1.3",
"i18next": "^26.0.10",
"maplibre-gl": "^5.24.0",
@ -5287,6 +5288,97 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
"integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
"integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
"integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
"integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
"integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry-internal/feedback": "10.53.1",
"@sentry-internal/replay": "10.53.1",
"@sentry-internal/replay-canvas": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
"integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz",
"integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",

View file

@ -27,6 +27,7 @@
"@protomaps/basemaps": "^5.7.2",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@sentry/react": "^10.53.1",
"@types/supercluster": "^7.1.3",
"i18next": "^26.0.10",
"maplibre-gl": "^5.24.0",

View file

@ -318,6 +318,7 @@ export default function App() {
const savedSearches = useSavedSearches(user?.id ?? null);
const [showSaveModal, setShowSaveModal] = useState(false);
const [editingSearch, setEditingSearch] = useState<{ id: string; name: string } | null>(null);
useEffect(() => {
const controller = new AbortController();
@ -374,11 +375,52 @@ export default function App() {
}
setRouteHash(targetHash);
setActivePage(page);
setEditingSearch(null);
if (targetHash) scrollToHash(targetHash);
},
[inviteCode]
);
const handleEditSearch = useCallback(
(id: string, name: string, params: string) => {
const search = params.startsWith('?') ? params : `?${params}`;
dashboardSearchRef.current = search;
const url = `/dashboard${search}`;
window.history.pushState({ page: 'dashboard', hash: '' }, '', url);
setMapUrlState(parseUrlState());
setDashboardRouteKey(search);
setRouteHash('');
setActivePage('dashboard');
setEditingSearch({ id, name });
},
[]
);
const handleCancelEdit = useCallback(() => {
setEditingSearch(null);
}, []);
const updateEditingSearch = useCallback(
async (params: string) => {
if (!editingSearch) return;
await savedSearches.updateSearchParams(editingSearch.id, params);
setEditingSearch(null);
},
[editingSearch, savedSearches]
);
const handleUpdateEdit = useCallback(
async (params: string) => {
try {
await updateEditingSearch(params);
navigateTo('saved');
} catch {
// Error stored on savedSearches.error
}
},
[updateEditingSearch, navigateTo]
);
useEffect(() => {
if (authLoading || !user || postAuthIntent !== 'checkout') return;
@ -439,6 +481,8 @@ export default function App() {
if (page === 'dashboard') {
setMapUrlState(parseUrlState());
setDashboardRouteKey(window.location.search);
} else {
setEditingSearch(null);
}
};
window.addEventListener('popstate', handlePopState);
@ -517,8 +561,17 @@ export default function App() {
onToggleTheme={toggleTheme}
exportState={activePage === 'dashboard' ? exportState : null}
dashboardParams={activePage === 'dashboard' ? dashboardParams : ''}
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
onSaveSearch={
activePage === 'dashboard' && user
? editingSearch
? () => handleUpdateEdit(dashboardParams)
: () => setShowSaveModal(true)
: null
}
savingSearch={savedSearches.saving}
editingSearch={activePage === 'dashboard' ? editingSearch : null}
onCancelEdit={handleCancelEdit}
onUpdateEdit={() => handleUpdateEdit(dashboardParams)}
user={user}
onLoginClick={() => openAuthModal('login')}
onRegisterClick={() => openAuthModal('register')}
@ -553,9 +606,7 @@ export default function App() {
onDeleteSearch={savedSearches.deleteSearch}
onUpdateSearchNotes={savedSearches.updateSearchNotes}
onUpdateSearchName={savedSearches.updateSearchName}
onOpenSearch={(params) => {
window.location.href = `/dashboard?${params}`;
}}
onOpenSearch={handleEditSearch}
/>
) : activePage === 'account' && user ? (
<AccountPage
@ -609,6 +660,10 @@ export default function App() {
deferTutorial={licenseSuccessStatus !== 'hidden'}
onSaveSearch={user ? savedSearches.saveSearch : undefined}
savingSearch={savedSearches.saving}
editingSearch={editingSearch}
onCancelEdit={handleCancelEdit}
onUpdateEdit={handleUpdateEdit}
onUpdateEditInPlace={updateEditingSearch}
/>
)}
</Suspense>

View file

@ -198,7 +198,7 @@ function SavedSearchesTab({
onDelete: (id: string) => Promise<void>;
onUpdateNotes: (id: string, notes: string) => void;
onUpdateName: (id: string, name: string) => void;
onOpen: (params: string) => void;
onOpen: (id: string, name: string, params: string) => void;
}) {
const { t, i18n } = useTranslation();
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
@ -302,7 +302,7 @@ function SavedSearchesTab({
<div className="flex gap-2 mt-auto">
<button
onClick={() => onOpen(search.params)}
onClick={() => onOpen(search.id, search.name, search.params)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
>
{t('common.open')}
@ -358,7 +358,7 @@ export function SavedPage({
onDeleteSearch: (id: string) => Promise<void>;
onUpdateSearchNotes: (id: string, notes: string) => void;
onUpdateSearchName: (id: string, name: string) => void;
onOpenSearch: (params: string) => void;
onOpenSearch: (id: string, name: string, params: string) => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'searches' | 'shared-links'>(

View file

@ -126,9 +126,6 @@ function ProductDemoVideo() {
ref={sectionRef}
className={`${HOME_SECTION_CONTAINER_CLASS} pt-8 md:pt-12 pb-2`}
>
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-5 text-center`}>
{t('home.productDemoLabel')}
</h2>
<div
className={`relative overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700 ${
isMobile ? 'mx-auto max-w-sm' : ''

View file

@ -84,7 +84,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
{
name: 'Good+ primary schools within 2km',
type: 'numeric',
group: 'Education',
group: 'Schools',
min: 0,
max: 8,
step: 1,
@ -92,7 +92,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
{
name: 'Noise (dB)',
type: 'numeric',
group: 'Environment',
group: 'Defining characteristics',
min: 40,
max: 80,
step: 1,

View file

@ -33,6 +33,7 @@ import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
import { FeatureLabel } from '../ui/FeatureLabel';
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
import StreetViewEmbed from './StreetViewEmbed';
import HistogramLegend from './HistogramLegend';
import JourneyInstructions from './JourneyInstructions';
@ -43,7 +44,6 @@ interface AreaPaneProps {
loading: boolean;
hexagonId: string | null;
isPostcode?: boolean;
onViewProperties: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
unfilteredCount?: number | null;
@ -75,19 +75,12 @@ function filterValueFormat(feature?: FeatureMeta) {
};
}
function formatExclusionPercent(value: number): string {
const percent = value * 100;
if (percent < 10) return `${percent.toFixed(1)}%`;
return `${Math.round(percent)}%`;
}
export default function AreaPane({
stats,
globalFeatures,
loading,
hexagonId,
isPostcode = false,
onViewProperties,
hexagonLocation,
filters,
unfilteredCount,
@ -105,7 +98,6 @@ export default function AreaPane({
const filtersActive = activeFilterCount > 0;
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
const showFlipToggleCallout = filteredStatsEmpty && unfilteredCount !== 0;
const canViewProperties = stats && stats.count > 0 && (statsUseFilters || !filtersActive);
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -144,6 +136,9 @@ export default function AreaPane({
};
const getExclusionAdjustment = (exclusion: FilterExclusion) => {
if (exclusion.direction === 'missing_value') {
return t('areaPane.missingFilterValue');
}
if (exclusion.direction === 'allow_value') {
return t('areaPane.allowCategory', { value: ts(exclusion.category ?? '') });
}
@ -167,7 +162,9 @@ export default function AreaPane({
return (
<>
<div className="h-full overflow-y-auto">
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && stats != null} />
<div className="flex-1 overflow-y-auto">
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
<div className="space-y-3 p-3">
<div className="flex items-start justify-between gap-3">
@ -264,14 +261,7 @@ export default function AreaPane({
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
>
<div className="flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate font-medium">
{getExclusionLabel(exclusion)}
</span>
<span className="shrink-0 tabular-nums text-amber-700 dark:text-amber-200">
{formatExclusionPercent(exclusion.relative_difference)}
</span>
</div>
<div className="truncate font-medium">{getExclusionLabel(exclusion)}</div>
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
{getExclusionAdjustment(exclusion)}
</p>
@ -282,14 +272,6 @@ export default function AreaPane({
)}
</div>
)}
{canViewProperties && (
<button
onClick={onViewProperties}
className="w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
>
{t('areaPane.viewPropertiesShort')}
</button>
)}
</div>
</div>
@ -621,6 +603,7 @@ export default function AreaPane({
})}
</div>
) : null}
</div>
</div>
{infoFeature && (

View file

@ -44,6 +44,8 @@ export default function ExternalSearchLinks({
if (!urls) return null;
const primaryLinkClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded bg-teal-600 hover:bg-teal-700 text-white dark:bg-teal-500 dark:text-navy-950 dark:hover:bg-teal-400 font-medium shadow-sm';
const linkClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium';
const disabledClass =
@ -56,7 +58,12 @@ export default function ExternalSearchLinks({
</h3>
<div className="flex flex-wrap gap-2">
{rightmoveHref ? (
<a href={rightmoveHref} target="_blank" rel="noopener noreferrer" className={linkClass}>
<a
href={rightmoveHref}
target="_blank"
rel="noopener noreferrer"
className={primaryLinkClass}
>
Rightmove
</a>
) : (

View file

@ -161,7 +161,7 @@ export default function FeatureBrowser({
title={t('filters.aboutData')}
size="md"
>
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
<InfoIcon className="w-4 h-4" />
</IconButton>
<button
type="button"

View file

@ -5,6 +5,7 @@ import type { FeatureMeta, FeatureFilters } from '../../types';
import { findActiveFilterElement } from '../../lib/active-filter-scroll';
import { buildPercentileScale } from '../../lib/format';
import type { PercentileScale } from '../../lib/format';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import InfoPopup from '../ui/InfoPopup';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import type { AiFilterErrorType } from '../../hooks/useAiFilters';
@ -69,7 +70,7 @@ interface FiltersProps {
onAddFilter: (name: string) => void;
onRemoveFilter: (name: string) => void;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
pinnedFeature: string | null;
@ -105,6 +106,9 @@ interface FiltersProps {
onClearAll: () => void;
onSaveSearch?: (name: string) => Promise<void>;
savingSearch?: boolean;
editingSearchName?: string | null;
onUpdateSearch?: () => Promise<void>;
onExitEditing?: () => void;
destinationDropdownPortal?: boolean;
}
@ -147,6 +151,9 @@ export default memo(function Filters({
onClearAll,
onSaveSearch,
savingSearch,
editingSearchName,
onUpdateSearch,
onExitEditing,
destinationDropdownPortal = true,
}: FiltersProps) {
const { t } = useTranslation();
@ -228,7 +235,7 @@ export default memo(function Filters({
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? schoolMeta), name, group: 'Education' };
return { ...(backendFeature ?? schoolMeta), name, group: 'Schools' };
});
}, [filters, features, schoolMeta]);
const specificCrimeFilterItems = useMemo(() => {
@ -423,46 +430,88 @@ export default memo(function Filters({
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
const [activeFilterCollapsed, setActiveFilterCollapsed] = useState(false);
const [addFilterCollapsed, setAddFilterCollapsed] = useState(false);
const [isActiveFilterGroupExpanded, toggleActiveFilterGroup, expandActiveFilterGroup] =
useCollapsibleGroups();
const activeEntryCount = travelTimeEntries.length;
const pendingScrollRef = useRef<string | null>(null);
const highlightTimeoutRef = useRef<number | null>(null);
const queueActiveFilterScroll = useCallback(
(filterName: string, groupName: string | null | undefined) => {
if (groupName) expandActiveFilterGroup(groupName);
pendingScrollRef.current = filterName;
},
[expandActiveFilterGroup]
);
const getAddFilterGroupName = useCallback(
(name: string): string | null => {
if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Schools';
if (name === SPECIFIC_CRIMES_FILTER_NAME) return specificCrimeMeta.group ?? 'Crime';
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
return electionVoteShareMeta.group ?? 'Neighbours';
}
if (name === ETHNICITIES_FILTER_NAME) return ethnicityMeta.group ?? 'Neighbours';
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
return poiFilterMetas[name as PoiFilterName].group ?? null;
}
return features.find((feature) => feature.name === name)?.group ?? null;
},
[
electionVoteShareMeta.group,
ethnicityMeta.group,
features,
poiFilterMetas,
schoolMeta.group,
specificCrimeMeta.group,
]
);
const handleAddAndScroll = useCallback(
(name: string) => {
if (name === SCHOOL_FILTER_NAME) {
if (!defaultSchoolFeatureName) return;
pendingScrollRef.current = SCHOOL_FILTER_NAME;
queueActiveFilterScroll(SCHOOL_FILTER_NAME, getAddFilterGroupName(SCHOOL_FILTER_NAME));
onAddFilter(SCHOOL_FILTER_NAME);
return;
}
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
if (!defaultSpecificCrimeFeatureName) return;
pendingScrollRef.current = SPECIFIC_CRIMES_FILTER_NAME;
queueActiveFilterScroll(
SPECIFIC_CRIMES_FILTER_NAME,
getAddFilterGroupName(SPECIFIC_CRIMES_FILTER_NAME)
);
onAddFilter(SPECIFIC_CRIMES_FILTER_NAME);
return;
}
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
if (!defaultElectionVoteShareFeatureName) return;
pendingScrollRef.current = ELECTION_VOTE_SHARE_FILTER_NAME;
queueActiveFilterScroll(
ELECTION_VOTE_SHARE_FILTER_NAME,
getAddFilterGroupName(ELECTION_VOTE_SHARE_FILTER_NAME)
);
onAddFilter(ELECTION_VOTE_SHARE_FILTER_NAME);
return;
}
if (name === ETHNICITIES_FILTER_NAME) {
if (!defaultEthnicityFeatureName) return;
pendingScrollRef.current = ETHNICITIES_FILTER_NAME;
queueActiveFilterScroll(
ETHNICITIES_FILTER_NAME,
getAddFilterGroupName(ETHNICITIES_FILTER_NAME)
);
onAddFilter(ETHNICITIES_FILTER_NAME);
return;
}
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
const filterName = name as PoiFilterName;
if (!defaultPoiFilterFeatureNames[filterName]) return;
pendingScrollRef.current = filterName;
queueActiveFilterScroll(filterName, getAddFilterGroupName(filterName));
onAddFilter(filterName);
return;
}
pendingScrollRef.current = name;
queueActiveFilterScroll(name, getAddFilterGroupName(name));
onAddFilter(name);
},
[
@ -471,16 +520,18 @@ export default memo(function Filters({
defaultElectionVoteShareFeatureName,
defaultEthnicityFeatureName,
defaultPoiFilterFeatureNames,
getAddFilterGroupName,
onAddFilter,
queueActiveFilterScroll,
]
);
const handleAddTravelTimeAndScroll = useCallback(
(mode: TransportMode) => {
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
queueActiveFilterScroll(`tt_${travelTimeEntries.length}`, 'Transport');
onTravelTimeAddEntry(mode);
},
[onTravelTimeAddEntry, travelTimeEntries.length]
[onTravelTimeAddEntry, queueActiveFilterScroll, travelTimeEntries.length]
);
useEffect(() => {
@ -516,9 +567,6 @@ export default memo(function Filters({
return scales;
}, [features]);
// Keep commute controls at the top of active filters, before other Transport filters.
const travelInsertIdx = 0;
const badgeCount = enabledFeatureList.length + activeEntryCount;
const [showClearPopup, setShowClearPopup] = useState(false);
@ -527,14 +575,14 @@ export default memo(function Filters({
const handleClearAllClick = useCallback(() => {
if (badgeCount === 0) return;
if (onSaveSearch) {
if (onUpdateSearch || onSaveSearch) {
setShowClearPopup(true);
setClearSaveName('');
setClearSaveError(null);
} else {
onClearAll();
}
}, [badgeCount, onSaveSearch, onClearAll]);
}, [badgeCount, onUpdateSearch, onSaveSearch, onClearAll]);
const handleSaveAndClear = useCallback(
async (e: FormEvent) => {
@ -551,10 +599,22 @@ export default memo(function Filters({
[clearSaveName, savingSearch, onSaveSearch, onClearAll, t]
);
const handleUpdateAndClear = useCallback(async () => {
if (savingSearch || !onUpdateSearch) return;
try {
await onUpdateSearch();
setShowClearPopup(false);
onClearAll();
} catch {
setClearSaveError(t('saveSearch.saving'));
}
}, [savingSearch, onUpdateSearch, onClearAll, t]);
const handleClearWithoutSaving = useCallback(() => {
setShowClearPopup(false);
onClearAll();
}, [onClearAll]);
if (editingSearchName) onExitEditing?.();
}, [onClearAll, editingSearchName, onExitEditing]);
return (
<div
@ -574,10 +634,11 @@ export default memo(function Filters({
dragValue={dragValue}
pinnedFeature={pinnedFeature}
travelTimeEntries={travelTimeEntries}
travelInsertIdx={travelInsertIdx}
filterImpacts={filterImpacts}
percentileScales={percentileScales}
destinationDropdownPortal={destinationDropdownPortal}
isGroupExpanded={isActiveFilterGroupExpanded}
onToggleGroup={toggleActiveFilterGroup}
aiFilterLoading={aiFilterLoading}
aiFilterError={aiFilterError}
aiFilterErrorType={aiFilterErrorType}
@ -689,9 +750,11 @@ export default memo(function Filters({
saveName={clearSaveName}
saveError={clearSaveError}
savingSearch={savingSearch}
editingSearchName={editingSearchName ?? null}
onClose={() => setShowClearPopup(false)}
onSaveNameChange={setClearSaveName}
onSaveAndClear={handleSaveAndClear}
onUpdateAndClear={onUpdateSearch ? handleUpdateAndClear : undefined}
onClearWithoutSaving={handleClearWithoutSaving}
/>
</div>

View file

@ -0,0 +1,91 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import JourneyInstructions, { googleMapsUrl } from './JourneyInstructions';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string | number>) => {
if (key === 'areaPane.to') return `To ${values?.destination}`;
if (key === 'areaPane.journeysFrom') return `Journeys from ${values?.label}`;
if (key === 'common.min') return 'min';
if (key === 'common.loading') return 'Loading';
if (key === 'travel.bestCase') return 'Best case';
if (key === 'areaPane.walk') return 'Walk';
if (key === 'areaPane.cycle') return 'Cycle';
if (key === 'areaPane.viewOnGoogleMaps') return 'View on Google Maps';
if (key === 'areaPane.noJourneyData') return 'No journey data';
return key;
},
}),
}));
describe('JourneyInstructions', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('keeps the transit leg breakdown visible when best-case time is selected', () => {
render(
<JourneyInstructions
postcode="E14 2DG"
entries={[]}
presetJourneys={[
{
slug: 'bank',
label: 'Bank',
minutes: 42,
bestMinutes: 25,
useBest: true,
legs: [
{ mode: 'walk', minutes: 8 },
{
mode: 'Jubilee',
from: 'Canary Wharf (9400ZZLUCAW)',
to: 'London Bridge (9400ZZLULNB)',
minutes: 7,
},
{
mode: 'Northern',
from: 'London Bridge (9400ZZLULNB)',
to: 'Bank (9400ZZLUBNK)',
minutes: 3,
},
],
},
]}
showGoogleMapsLink={false}
/>
);
expect(screen.getByText(/Best case/)).toBeTruthy();
expect(screen.getByText('Jubilee line')).toBeTruthy();
expect(screen.getByText('Northern line')).toBeTruthy();
expect(screen.getByText(/Canary Wharf/)).toBeTruthy();
});
it('builds explicit Google Maps transit directions with destination coordinates', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-16T12:00:00Z'));
const url = googleMapsUrl('NW7 2GA', 'Bank tube station', 51.5132819, -0.0895555);
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe('https://www.google.com/maps/dir/');
expect(parsed.searchParams.get('api')).toBe('1');
expect(parsed.searchParams.get('origin')).toBe('NW7 2GA');
expect(parsed.searchParams.get('destination')).toBe('51.5132819,-0.0895555');
expect(parsed.searchParams.get('travelmode')).toBe('transit');
expect(parsed.searchParams.get('departure_time')).toBe('1779085800');
});
it('does not rewrite destination names when coordinates are unavailable', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-16T12:00:00Z'));
const parsed = new URL(googleMapsUrl('NW7 2GA', 'Bank tube station'));
expect(parsed.searchParams.get('destination')).toBe('Bank tube station');
});
});

View file

@ -26,6 +26,8 @@ interface JourneyData {
minutes: number | null;
/** Best-case (5th percentile) total travel time from R5. */
bestMinutes: number | null;
destinationLat: number | null;
destinationLon: number | null;
/** Whether the dashboard filter is currently using best-case time. */
useBest: boolean;
loading: boolean;
@ -39,6 +41,8 @@ export interface JourneyInstructionPreset {
minutes: number | null;
/** Best-case (5th percentile) total travel time. */
bestMinutes?: number | null;
destinationLat?: number | null;
destinationLon?: number | null;
useBest?: boolean;
}
@ -94,15 +98,37 @@ function nextMondayAt730(): number {
return Math.floor(monday.getTime() / 1000);
}
function googleMapsUrl(origin: string, destination: string): string {
function googleMapsDestination(
destination: string,
destinationLat?: number | null,
destinationLon?: number | null
): string {
if (
destinationLat != null &&
destinationLon != null &&
Number.isFinite(destinationLat) &&
Number.isFinite(destinationLon)
) {
return `${destinationLat},${destinationLon}`;
}
return stripId(destination).trim();
}
export function googleMapsUrl(
origin: string,
destination: string,
destinationLat?: number | null,
destinationLon?: number | null
): string {
const ts = nextMondayAt730();
const encodedOrigin = encodeURIComponent(origin);
const encodedDestination = 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/${encodedOrigin}/${encodedDestination}/data=${data}`;
const params = new URLSearchParams({
api: '1',
origin,
destination: googleMapsDestination(destination, destinationLat, destinationLon),
travelmode: 'transit',
departure_time: ts.toString(),
});
return `https://www.google.com/maps/dir/?${params.toString()}`;
}
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
@ -215,6 +241,8 @@ export default function JourneyInstructions({
legs: null,
minutes: null,
bestMinutes: null,
destinationLat: null,
destinationLon: null,
useBest: e.useBest,
loading: true,
}));
@ -237,17 +265,21 @@ export default function JourneyInstructions({
journey: JourneyLeg[] | null;
minutes: number | null;
best_minutes: number | null;
destination_lat?: number | null;
destination_lon?: number | null;
}) => {
setJourneys((prev) =>
prev.map((j, i) =>
i === idx
? {
...j,
legs: data.journey,
minutes: data.minutes,
bestMinutes: data.best_minutes,
loading: false,
}
...j,
legs: data.journey,
minutes: data.minutes,
bestMinutes: data.best_minutes,
destinationLat: data.destination_lat ?? null,
destinationLon: data.destination_lon ?? null,
loading: false,
}
: j
)
);
@ -266,14 +298,16 @@ export default function JourneyInstructions({
const displayedJourneys: JourneyData[] = hasPresetJourneys
? (presetJourneys ?? []).map((journey) => ({
slug: journey.slug,
label: journey.label,
legs: journey.legs,
minutes: journey.minutes,
bestMinutes: journey.bestMinutes ?? null,
useBest: journey.useBest ?? false,
loading: false,
}))
slug: journey.slug,
label: journey.label,
legs: journey.legs,
minutes: journey.minutes,
bestMinutes: journey.bestMinutes ?? null,
destinationLat: journey.destinationLat ?? null,
destinationLon: journey.destinationLon ?? null,
useBest: journey.useBest ?? false,
loading: false,
}))
: journeys;
return (
@ -287,7 +321,7 @@ export default function JourneyInstructions({
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
const totalMin = j.useBest && j.bestMinutes != null ? j.bestMinutes : (j.minutes ?? legSum);
const isBestCase = j.useBest && j.bestMinutes != null;
const displayLegs = !isBestCase && j.legs ? invertLegs(j.legs) : null;
const displayLegs = j.legs ? invertLegs(j.legs) : null;
const destination = j.label || j.slug;
return (
@ -317,7 +351,7 @@ export default function JourneyInstructions({
))}
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, destination)}
href={googleMapsUrl(postcode, destination, j.destinationLat, j.destinationLon)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
@ -352,7 +386,7 @@ export default function JourneyInstructions({
</div>
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, destination)}
href={googleMapsUrl(postcode, destination, j.destinationLat, j.destinationLon)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"

View file

@ -15,6 +15,7 @@ import type {
FeatureMeta,
Bounds,
MapFlyToOptions,
ActualListing,
} from '../../types';
import {
@ -41,6 +42,7 @@ interface MapProps {
postcodeData: PostcodeFeature[];
usePostcodeView: boolean;
pois: POI[];
actualListings?: ActualListing[];
onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null;
colorRange: [number, number] | null;
@ -77,6 +79,20 @@ interface MapProps {
}
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
function formatListingPrice(price: number): string {
return `£${price.toLocaleString()}`;
}
function formatListingHeadline(listing: ActualListing): string | null {
const parts: string[] = [];
if (listing.bedrooms != null) parts.push(`${listing.bedrooms} bed`);
if (listing.bathrooms != null) parts.push(`${listing.bathrooms} bath`);
if (listing.property_sub_type) parts.push(listing.property_sub_type);
else if (listing.property_type) parts.push(listing.property_type);
return parts.length > 0 ? parts.join(' · ') : null;
}
interface Dimensions {
width: number;
@ -263,6 +279,7 @@ export default memo(function Map({
postcodeData,
usePostcodeView,
pois,
actualListings = EMPTY_ACTUAL_LISTINGS,
onViewChange,
viewFeature,
colorRange,
@ -442,6 +459,8 @@ export default memo(function Map({
layers,
popupInfo,
clearPopupInfo,
listingPopup,
clearListingPopup,
hoverPosition,
countRange,
postcodeCountRange,
@ -453,6 +472,7 @@ export default memo(function Map({
usePostcodeView,
zoom: viewState.zoom,
pois,
actualListings,
viewFeature,
colorRange,
filterRange,
@ -677,6 +697,77 @@ export default memo(function Map({
)}
</div>
)}
{listingPopup && (
<div
className="pointer-events-auto absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white max-w-[280px]"
style={{
left: listingPopup.x,
top: listingPopup.y - 12,
transform: 'translate(-50%, -100%)',
zIndex: 9999,
}}
onMouseLeave={clearListingPopup}
>
<button
className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
onClick={clearListingPopup}
>
<CloseIcon className="w-3 h-3" />
</button>
<a
href={listingPopup.listing.listing_url}
target="_blank"
rel="noopener noreferrer"
className="block px-3 py-2"
>
{listingPopup.listing.asking_price != null && (
<div className="text-base font-bold text-teal-600 dark:text-teal-400">
{formatListingPrice(listingPopup.listing.asking_price)}
{listingPopup.listing.price_qualifier ? (
<span className="ml-1 text-xs font-medium text-warm-500 dark:text-warm-400">
{listingPopup.listing.price_qualifier}
</span>
) : null}
</div>
)}
{formatListingHeadline(listingPopup.listing) && (
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
{formatListingHeadline(listingPopup.listing)}
</div>
)}
{listingPopup.listing.address && (
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5 line-clamp-2">
{listingPopup.listing.address}
</div>
)}
{listingPopup.listing.postcode && (
<div className="text-[11px] text-warm-400 dark:text-warm-500 mt-0.5">
{listingPopup.listing.postcode}
</div>
)}
{listingPopup.listing.floor_area_sqm != null && (
<div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
{Math.round(listingPopup.listing.floor_area_sqm)} sqm
{listingPopup.listing.asking_price_per_sqm != null
? ` · £${Math.round(listingPopup.listing.asking_price_per_sqm).toLocaleString()}/sqm`
: ''}
</div>
)}
{listingPopup.listing.features.length > 0 && (
<ul className="mt-1.5 text-[11px] text-warm-600 dark:text-warm-300 list-disc pl-4 space-y-0.5">
{listingPopup.listing.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="line-clamp-1">
{feature}
</li>
))}
</ul>
)}
<div className="mt-1.5 text-[11px] text-teal-600 dark:text-teal-400 font-medium">
Open listing
</div>
</a>
</div>
)}
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
<HoverCard
x={hoverPosition.x}

View file

@ -1,10 +1,11 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useActualListings } from '../../hooks/useActualListings';
import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
@ -82,6 +83,10 @@ export default function MapPage({
deferTutorial = false,
onSaveSearch,
savingSearch,
editingSearch,
onCancelEdit,
onUpdateEdit,
onUpdateEditInPlace,
}: MapPageProps) {
const { t } = useTranslation();
const [selectedPOICategories, setSelectedPOICategories] =
@ -164,6 +169,7 @@ export default function MapPage({
viewFeature,
activeFeature,
pinnedFeature,
filterRange,
travelTimeEntries: entries,
shareCode,
});
@ -283,7 +289,6 @@ export default function MapPage({
setRightPaneTab,
handleHexagonClick,
handleHexagonHover,
handleViewPropertiesFromArea,
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
@ -403,6 +408,10 @@ export default function MapPage({
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const { listings: actualListings } = useActualListings(
mapData.bounds,
mapData.currentView?.zoom ?? 0
);
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
@ -506,6 +515,9 @@ export default function MapPage({
},
[dashboardParams, onSaveSearch]
);
const handleUpdateEditInPlaceWithParams = useCallback(async () => {
await onUpdateEditInPlace?.(dashboardParams);
}, [dashboardParams, onUpdateEditInPlace]);
const checkoutReturnPath = useMemo(
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
[dashboardParams]
@ -543,7 +555,6 @@ export default function MapPage({
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
onViewProperties={handleViewPropertiesFromArea}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
@ -621,6 +632,11 @@ export default function MapPage({
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch ? handleSaveSearch : undefined}
savingSearch={savingSearch}
editingSearchName={editingSearch?.name ?? null}
onUpdateSearch={
editingSearch && onUpdateEditInPlace ? handleUpdateEditInPlaceWithParams : undefined
}
onExitEditing={onCancelEdit}
destinationDropdownPortal={options?.destinationDropdownPortal}
/>
</Suspense>
@ -643,6 +659,40 @@ export default function MapPage({
/>
);
const toasts = exportToast;
const editingBar =
editingSearch && isMobile ? (
<div className="flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-warm-50 dark:bg-navy-900">
<span
className="flex-1 min-w-0 truncate text-xs text-warm-700 dark:text-warm-200"
title={editingSearch.name}
>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{
strong: (
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
),
}}
/>
</span>
<button
onClick={onCancelEdit}
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-100 dark:hover:bg-navy-800"
>
{t('common.cancel')}
</button>
<button
onClick={() => onUpdateEdit?.(dashboardParams)}
disabled={savingSearch}
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5"
>
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
) : null;
const upgradeModal = mapData.licenseRequired ? (
<Suspense fallback={null}>
<UpgradeModal
@ -683,6 +733,7 @@ export default function MapPage({
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
travelTimeEntries={entries}
bottomScreenInset={mobileBottomSheetHeight}
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
@ -714,6 +765,7 @@ export default function MapPage({
renderPropertiesPane={renderPropertiesPane}
toasts={toasts}
upgradeModal={upgradeModal}
editingBar={editingBar}
/>
);
}
@ -745,6 +797,7 @@ export default function MapPage({
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}

View file

@ -9,6 +9,7 @@ interface VisualViewportState {
interface MobileBottomSheetProps {
children: ReactNode;
legend?: ReactNode;
editingBar?: ReactNode;
onCoveredHeightChange?: (height: number) => void;
}
@ -104,6 +105,7 @@ function getKeyboardEditableElement(target: EventTarget | null): HTMLElement | n
export default function MobileBottomSheet({
children,
legend,
editingBar,
onCoveredHeightChange,
}: MobileBottomSheetProps) {
const [keyboardAvoidanceActive, setKeyboardAvoidanceActive] = useState(false);
@ -244,6 +246,8 @@ export default function MobileBottomSheet({
</div>
</div>
{editingBar && <div className="shrink-0">{editingBar}</div>}
{legend && (
<div className="shrink-0 border-y border-warm-200 dark:border-navy-700">{legend}</div>
)}

View file

@ -7,6 +7,7 @@ import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { EmptyState } from '../ui/EmptyState';
import { InfoIcon } from '../ui/icons';
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
import { ts } from '../../i18n/server';
interface PropertiesPaneProps {
@ -57,7 +58,9 @@ export function PropertiesPane({
}
return (
<div className="h-full overflow-y-auto">
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && properties.length > 0} />
<div className="flex-1 overflow-y-auto">
{showInfo && (
<InfoPopup
title={t('propertyCard.propertyData')}
@ -116,6 +119,7 @@ export function PropertiesPane({
</>
)}
</div>
</div>
</div>
);
}

View file

@ -25,7 +25,7 @@ interface TravelTimeCardProps {
onTogglePin: () => void;
onSetDestination: (slug: string, label: string, lat: number, lon: number) => void;
onTimeRangeChange: (range: [number, number]) => void;
onDragStart: () => void;
onDragStart: (range: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onToggleBest: () => void;
@ -86,16 +86,17 @@ export function TravelTimeCard({
</span>
</div>
<div className="flex items-center gap-2 md:gap-0.5">
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')}>
<InfoIcon className="w-3.5 h-3.5" />
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')} size="md">
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
{slug && (
<IconButton
onClick={onTogglePin}
active={isPinned || isActive}
title={isPinned ? t('filters.clearColourMap') : t('filters.colourMap')}
size="md"
>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
<EyeIcon className="w-5 h-5 md:w-3.5 md:h-3.5" filled={isPinned || isActive} />
</IconButton>
)}
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>
@ -152,7 +153,7 @@ export function TravelTimeCard({
step={1}
value={[displayRange[0], displayRange[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart()}
onPointerDown={() => onDragStart(displayRange)}
onPointerUp={() => onDragEnd()}
/>
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">

View file

@ -1,7 +1,8 @@
import { Fragment } from 'react';
import { useMemo } from 'react';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import type { PercentileScale } from '../../../lib/format';
import { groupFeaturesByCategory } from '../../../lib/features';
import { getSpecificCrimeFeatureName, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
import {
getElectionVoteShareFeatureName,
@ -22,6 +23,9 @@ import { ElectionVoteShareFilterCard } from './ElectionVoteShareFilterCard';
import { EnumFeatureFilterCard } from './EnumFeatureFilterCard';
import { NumericFeatureFilterCard } from './NumericFeatureFilterCard';
import { TravelTimeFilterCards } from './TravelTimeFilterCards';
import { CollapsibleGroupHeader } from '../../ui/CollapsibleGroupHeader';
const TRANSPORT_GROUP = 'Transport';
interface ActiveFilterListProps {
features: FeatureMeta[];
@ -31,13 +35,14 @@ interface ActiveFilterListProps {
dragValue: [number, number] | null;
pinnedFeature: string | null;
travelTimeEntries: TravelTimeEntry[];
travelInsertIdx: number;
filterImpacts?: Record<string, number>;
percentileScales: Map<string, PercentileScale>;
destinationDropdownPortal: boolean;
isGroupExpanded: (name: string) => boolean;
onToggleGroup: (name: string) => void;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onRemoveFilter: (name: string) => void;
onDragStart: (name: string) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@ -63,10 +68,11 @@ export function ActiveFilterList({
dragValue,
pinnedFeature,
travelTimeEntries,
travelInsertIdx,
filterImpacts,
percentileScales,
destinationDropdownPortal,
isGroupExpanded,
onToggleGroup,
onFilterChange,
onRemoveFilter,
onDragStart,
@ -99,194 +105,208 @@ export function ActiveFilterList({
/>
);
const groupedFeatures = useMemo(() => {
const groups = groupFeaturesByCategory(enabledFeatureList);
const transportGroup = groups.find((group) => group.name === TRANSPORT_GROUP);
const otherGroups = groups.filter((group) => group.name !== TRANSPORT_GROUP);
if (transportGroup) return [transportGroup, ...otherGroups];
if (travelTimeEntries.length > 0)
return [{ name: TRANSPORT_GROUP, features: [] }, ...otherGroups];
return otherGroups;
}, [enabledFeatureList, travelTimeEntries.length]);
const renderFeatureCard = (feature: FeatureMeta) => {
if (isSchoolFilterName(feature.name)) {
const schoolBackendName = getSchoolBackendFeatureName(feature.name);
return (
<SchoolFilterCard
key={feature.name}
features={features}
schoolFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={schoolBackendName ? filterImpacts?.[schoolBackendName] : undefined}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
);
}
if (isSpecificCrimeFilterName(feature.name)) {
const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
return (
<SpecificCrimeFilterCard
key={feature.name}
features={features}
crimeFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
specificCrimeBackendName ? filterImpacts?.[specificCrimeBackendName] : undefined
}
percentileScale={
specificCrimeBackendName ? percentileScales.get(specificCrimeBackendName) : undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
);
}
if (isElectionVoteShareFilterName(feature.name)) {
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
return (
<ElectionVoteShareFilterCard
key={feature.name}
features={features}
voteShareFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
electionVoteShareBackendName ? filterImpacts?.[electionVoteShareBackendName] : undefined
}
percentileScale={
electionVoteShareBackendName
? percentileScales.get(electionVoteShareBackendName)
: undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
);
}
if (isEthnicityFilterName(feature.name)) {
const ethnicityBackendName = getEthnicityFeatureName(feature.name);
return (
<EthnicityFilterCard
key={feature.name}
features={features}
ethnicityFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={ethnicityBackendName ? filterImpacts?.[ethnicityBackendName] : undefined}
percentileScale={
ethnicityBackendName ? percentileScales.get(ethnicityBackendName) : undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
);
}
if (isPoiDistanceFilterName(feature.name)) {
const poiBackendName = getPoiDistanceFeatureName(feature.name);
return (
<PoiDistanceFilterCard
key={feature.name}
features={features}
poiFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={poiBackendName ? filterImpacts?.[poiBackendName] : undefined}
percentileScale={poiBackendName ? percentileScales.get(poiBackendName) : undefined}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
);
}
return feature.type === 'enum' ? (
<EnumFeatureFilterCard
key={feature.name}
feature={feature}
filters={filters}
pinnedFeature={pinnedFeature}
filterImpact={filterImpacts?.[feature.name]}
onFilterChange={onFilterChange}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemoveFilter={onRemoveFilter}
/>
) : (
<NumericFeatureFilterCard
key={feature.name}
feature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={filterImpacts?.[feature.name]}
percentileScale={percentileScales.get(feature.name)}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemoveFilter={onRemoveFilter}
/>
);
};
return (
<div className="px-2 py-1 space-y-1">
{enabledFeatureList.map((feature, featureIdx) => {
const insertTravelCards = featureIdx === travelInsertIdx;
if (isSchoolFilterName(feature.name)) {
const schoolBackendName = getSchoolBackendFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<SchoolFilterCard
features={features}
schoolFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={schoolBackendName ? filterImpacts?.[schoolBackendName] : undefined}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isSpecificCrimeFilterName(feature.name)) {
const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<SpecificCrimeFilterCard
features={features}
crimeFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
specificCrimeBackendName ? filterImpacts?.[specificCrimeBackendName] : undefined
}
percentileScale={
specificCrimeBackendName
? percentileScales.get(specificCrimeBackendName)
: undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isElectionVoteShareFilterName(feature.name)) {
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<ElectionVoteShareFilterCard
features={features}
voteShareFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
electionVoteShareBackendName
? filterImpacts?.[electionVoteShareBackendName]
: undefined
}
percentileScale={
electionVoteShareBackendName
? percentileScales.get(electionVoteShareBackendName)
: undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isEthnicityFilterName(feature.name)) {
const ethnicityBackendName = getEthnicityFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<EthnicityFilterCard
features={features}
ethnicityFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
ethnicityBackendName ? filterImpacts?.[ethnicityBackendName] : undefined
}
percentileScale={
ethnicityBackendName ? percentileScales.get(ethnicityBackendName) : undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isPoiDistanceFilterName(feature.name)) {
const poiBackendName = getPoiDistanceFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<PoiDistanceFilterCard
features={features}
poiFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={poiBackendName ? filterImpacts?.[poiBackendName] : undefined}
percentileScale={poiBackendName ? percentileScales.get(poiBackendName) : undefined}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
<div>
{groupedFeatures.map((group) => {
const travelCount = group.name === TRANSPORT_GROUP ? travelTimeEntries.length : 0;
const count = group.features.length + travelCount;
if (count === 0) return null;
const expanded = isGroupExpanded(group.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
{feature.type === 'enum' ? (
<EnumFeatureFilterCard
feature={feature}
filters={filters}
pinnedFeature={pinnedFeature}
filterImpact={filterImpacts?.[feature.name]}
onFilterChange={onFilterChange}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemoveFilter={onRemoveFilter}
/>
) : (
<NumericFeatureFilterCard
feature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={filterImpacts?.[feature.name]}
percentileScale={percentileScales.get(feature.name)}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemoveFilter={onRemoveFilter}
/>
<div key={group.name} className="shrink-0">
<CollapsibleGroupHeader
name={group.name}
expanded={expanded}
onToggle={() => onToggleGroup(group.name)}
className="sticky top-0 z-10 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
</CollapsibleGroupHeader>
{expanded && (
<div className="px-2 py-1.5 space-y-3.5">
{group.name === TRANSPORT_GROUP && travelCards}
{group.features.map((feature) => renderFeatureCard(feature))}
</div>
)}
</Fragment>
</div>
);
})}
{travelInsertIdx >= enabledFeatureList.length && travelCards}
</div>
);
}

View file

@ -22,10 +22,11 @@ interface ActiveFiltersPanelProps {
dragValue: [number, number] | null;
pinnedFeature: string | null;
travelTimeEntries: TravelTimeEntry[];
travelInsertIdx: number;
filterImpacts?: Record<string, number>;
percentileScales: Map<string, PercentileScale>;
destinationDropdownPortal: boolean;
isGroupExpanded: (name: string) => boolean;
onToggleGroup: (name: string) => void;
aiFilterLoading: boolean;
aiFilterError: string | null;
aiFilterErrorType: AiFilterErrorType | null;
@ -39,7 +40,7 @@ interface ActiveFiltersPanelProps {
onLoginRequired: () => void;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onRemoveFilter: (name: string) => void;
onDragStart: (name: string) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@ -70,10 +71,11 @@ export function ActiveFiltersPanel({
dragValue,
pinnedFeature,
travelTimeEntries,
travelInsertIdx,
filterImpacts,
percentileScales,
destinationDropdownPortal,
isGroupExpanded,
onToggleGroup,
aiFilterLoading,
aiFilterError,
aiFilterErrorType,
@ -108,14 +110,14 @@ export function ActiveFiltersPanel({
>
<button
onClick={onToggleCollapsed}
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"
className="shrink-0 flex items-center justify-between border-y border-l-4 border-teal-300 border-l-teal-600 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:border-l-teal-300 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.activeFilters')}
</span>
{badgeCount > 0 && (
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<span className="rounded-full bg-teal-600 px-1.5 py-0.5 text-xs font-bold text-white ring-1 ring-teal-700 dark:bg-teal-300 dark:text-navy-950 dark:ring-teal-200">
{badgeCount}
</span>
)}
@ -135,14 +137,14 @@ export function ActiveFiltersPanel({
onClearAllClick();
}
}}
className="text-xs text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-200 underline"
className="text-xs text-teal-700 underline hover:text-teal-900 dark:text-teal-300 dark:hover:text-teal-200"
>
{t('filters.clearAll')}
</span>
)}
<ChevronIcon
direction={collapsed ? 'down' : 'up'}
className="w-4 h-4 text-warm-400 dark:text-warm-500"
className="w-4 h-4 text-warm-500 dark:text-warm-300"
/>
</div>
</button>
@ -182,10 +184,11 @@ export function ActiveFiltersPanel({
dragValue={dragValue}
pinnedFeature={pinnedFeature}
travelTimeEntries={travelTimeEntries}
travelInsertIdx={travelInsertIdx}
filterImpacts={filterImpacts}
percentileScales={percentileScales}
destinationDropdownPortal={destinationDropdownPortal}
isGroupExpanded={isGroupExpanded}
onToggleGroup={onToggleGroup}
onFilterChange={onFilterChange}
onRemoveFilter={onRemoveFilter}
onDragStart={onDragStart}

View file

@ -110,14 +110,14 @@ export function AddFilterPanel({
>
<button
onClick={onToggleCollapsed}
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"
className="shrink-0 flex items-center justify-between border-y border-l-4 border-teal-300 border-l-teal-600 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:border-l-teal-300 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.addFilter')}
</span>
<ChevronIcon
direction={collapsed ? 'down' : 'up'}
className="w-4 h-4 text-warm-400 dark:text-warm-500"
className="w-4 h-4 text-warm-500 dark:text-warm-300"
/>
</button>
{(!collapsed || !isLicensed) && (

View file

@ -1,5 +1,5 @@
import { useEffect, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
@ -8,9 +8,11 @@ interface ClearFiltersDialogProps {
saveName: string;
saveError: string | null;
savingSearch?: boolean;
editingSearchName?: string | null;
onClose: () => void;
onSaveNameChange: (value: string) => void;
onSaveAndClear: (e: FormEvent) => void;
onUpdateAndClear?: () => void;
onClearWithoutSaving: () => void;
}
@ -19,12 +21,15 @@ export function ClearFiltersDialog({
saveName,
saveError,
savingSearch,
editingSearchName,
onClose,
onSaveNameChange,
onSaveAndClear,
onUpdateAndClear,
onClearWithoutSaving,
}: ClearFiltersDialogProps) {
const { t } = useTranslation();
const isEditing = !!editingSearchName && !!onUpdateAndClear;
useEffect(() => {
if (!open) return;
@ -55,39 +60,74 @@ export function ClearFiltersDialog({
<CloseIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={onSaveAndClear} className="p-5 pt-2 space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-400">
{t('filters.clearAllSavePrompt')}
</p>
<div>
<input
type="text"
value={saveName}
onChange={(e) => onSaveNameChange(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={t('saveSearch.namePlaceholder')}
autoFocus
/>
{isEditing ? (
<div className="p-5 pt-2 space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-400">
<Trans
i18nKey="filters.clearAllUpdatePrompt"
values={{ name: editingSearchName }}
components={{
strong: (
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
),
}}
/>
</p>
{saveError && <p className="text-sm text-red-600 dark:text-red-300">{saveError}</p>}
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={onClearWithoutSaving}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('filters.clearWithoutUpdating')}
</button>
<button
type="button"
onClick={onUpdateAndClear}
disabled={savingSearch}
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('savedPage.updating') : t('filters.updateAndClear')}
</button>
</div>
</div>
{saveError && <p className="text-sm text-red-600 dark:text-red-300">{saveError}</p>}
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={onClearWithoutSaving}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('filters.clearWithoutSaving')}
</button>
<button
type="submit"
disabled={!saveName.trim() || savingSearch}
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
</button>
</div>
</form>
) : (
<form onSubmit={onSaveAndClear} className="p-5 pt-2 space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-400">
{t('filters.clearAllSavePrompt')}
</p>
<div>
<input
type="text"
value={saveName}
onChange={(e) => onSaveNameChange(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={t('saveSearch.namePlaceholder')}
autoFocus
/>
</div>
{saveError && <p className="text-sm text-red-600 dark:text-red-300">{saveError}</p>}
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={onClearWithoutSaving}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('filters.clearWithoutSaving')}
</button>
<button
type="submit"
disabled={!saveName.trim() || savingSearch}
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
</button>
</div>
</form>
)}
</div>
</div>
);

View file

@ -45,7 +45,7 @@ export function ElectionVoteShareFilterCard({
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@ -201,7 +201,7 @@ export function ElectionVoteShareFilterCard({
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(voteShareFeature.name)}
onPointerDown={() => onDragStart(voteShareFeature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels

View file

@ -45,7 +45,7 @@ export function EthnicityFilterCard({
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@ -197,7 +197,7 @@ export function EthnicityFilterCard({
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(ethnicityFeature.name)}
onPointerDown={() => onDragStart(ethnicityFeature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels

View file

@ -17,7 +17,7 @@ interface NumericFeatureFilterCardProps {
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@ -114,7 +114,7 @@ export function NumericFeatureFilterCard({
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
}
onPointerDown={() => onDragStart(feature.name)}
onPointerDown={() => onDragStart(feature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels

View file

@ -47,7 +47,7 @@ export function PoiDistanceFilterCard({
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@ -184,7 +184,7 @@ export function PoiDistanceFilterCard({
max >= sliderMax ? sliderMax : max,
])
}
onPointerDown={() => onDragStart(poiFeature.name)}
onPointerDown={() => onDragStart(poiFeature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels

View file

@ -41,7 +41,7 @@ export function SchoolFilterCard({
pinnedFeature: string | null;
filterImpact?: number;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@ -216,7 +216,7 @@ export function SchoolFilterCard({
max >= (backendFeature?.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(schoolFeature.name)}
onPointerDown={() => onDragStart(schoolFeature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels

View file

@ -45,7 +45,7 @@ export function SpecificCrimeFilterCard({
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@ -197,7 +197,7 @@ export function SpecificCrimeFilterCard({
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(crimeFeature.name)}
onPointerDown={() => onDragStart(crimeFeature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels

View file

@ -20,7 +20,7 @@ interface TravelTimeFilterCardsProps {
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
onDragStart: (name: string) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
}
@ -60,7 +60,7 @@ export function TravelTimeFilterCards({
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(fieldKey)}
onDragStart={(range) => onDragStart(fieldKey, range)}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}

View file

@ -1,13 +1,21 @@
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
import type {
ActualListing,
FeatureFilters,
FeatureMeta,
POI,
PostcodeGeometry,
ViewState,
} from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import type { useTutorial } from '../../../hooks/useTutorial';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
import type { SearchedLocation } from '../LocationSearch';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo, PaneResizeHandlers } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
import { LoadingOverlay } from './LoadingOverlay';
@ -44,6 +52,7 @@ interface DesktopMapPageProps {
onLocationSearched: (location: SearchedLocation | null) => void;
onCurrentLocationFound: (lat: number, lng: number) => void;
currentLocation: { lat: number; lng: number } | null;
actualListings: ActualListing[];
travelTimeEntries: TravelTimeEntry[];
densityLabel: string;
totalCount?: number;
@ -89,6 +98,7 @@ export function DesktopMapPage({
onLocationSearched,
onCurrentLocationFound,
currentLocation,
actualListings,
travelTimeEntries,
densityLabel,
totalCount,
@ -151,6 +161,7 @@ export function DesktopMapPage({
</div>
<div data-tutorial="map" className="flex-1 relative">
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
@ -178,6 +189,7 @@ export function DesktopMapPage({
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideTopCardsWhenNarrow
travelTimeEntries={travelTimeEntries}

View file

@ -1,11 +1,19 @@
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
import type {
ActualListing,
FeatureFilters,
FeatureMeta,
POI,
PostcodeGeometry,
ViewState,
} from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { SearchedLocation } from '../LocationSearch';
import MobileBottomSheet from '../MobileBottomSheet';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
import { LoadingOverlay } from './LoadingOverlay';
@ -35,6 +43,7 @@ interface MobileMapPageProps {
onLocationSearched: (location: SearchedLocation | null) => void;
onCurrentLocationFound: (lat: number, lng: number) => void;
currentLocation: { lat: number; lng: number } | null;
actualListings: ActualListing[];
travelTimeEntries: TravelTimeEntry[];
bottomScreenInset: number;
onBottomSheetCoveredHeightChange: (height: number) => void;
@ -53,6 +62,7 @@ interface MobileMapPageProps {
renderPropertiesPane: () => ReactNode;
toasts: ReactNode;
upgradeModal: ReactNode;
editingBar?: ReactNode;
}
export function MobileMapPage({
@ -76,6 +86,7 @@ export function MobileMapPage({
onLocationSearched,
onCurrentLocationFound,
currentLocation,
actualListings,
travelTimeEntries,
bottomScreenInset,
onBottomSheetCoveredHeightChange,
@ -94,12 +105,14 @@ export function MobileMapPage({
renderPropertiesPane,
toasts,
upgradeModal,
editingBar,
}: MobileMapPageProps) {
return (
<div className="flex-1 overflow-hidden relative">
<LoadingOverlay show={initialLoading} />
<div className="absolute inset-0">
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
@ -127,6 +140,7 @@ export function MobileMapPage({
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideLegend
hideLocationSearch={mobileDrawerOpen && !!selectedHexagonId}
@ -152,6 +166,7 @@ export function MobileMapPage({
<MobileBottomSheet
legend={mobileLegend}
editingBar={editingBar}
onCoveredHeightChange={onBottomSheetCoveredHeightChange}
>
{filtersPane}

View file

@ -47,6 +47,10 @@ export interface MapPageProps {
deferTutorial?: boolean;
onSaveSearch?: (name: string, paramsOverride?: string) => Promise<void>;
savingSearch?: boolean;
editingSearch?: { id: string; name: string } | null;
onCancelEdit?: () => void;
onUpdateEdit?: (params: string) => Promise<void>;
onUpdateEditInPlace?: (params: string) => Promise<void>;
}
export type MapFlyTo = (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;

View file

@ -6,7 +6,7 @@ import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../
import { trackEvent } from '../../../lib/analytics';
import type { ExportNotice, ExportState } from './types';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import { buildTravelParam } from '../../../lib/travel-params';
import { buildTravelParam, dedupeTravelTimeEntries } from '../../../lib/travel-params';
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
const EXPORT_TIMEOUT_MS = 150_000;
@ -68,7 +68,7 @@ function triggerExportDownload(blob: Blob, fileName: string): void {
}
function appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
for (const entry of entries) {
for (const entry of dedupeTravelTimeEntries(entries)) {
if (!entry.slug) continue;
let value = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.useBest) value += ':b';

View file

@ -212,7 +212,7 @@ export function DestinationDropdown({
(portal ? (
createPortal(dropdown, document.body)
) : (
<div className="absolute top-full left-0 right-0 mt-1 z-30">{dropdown}</div>
<div className="relative z-30 mt-1">{dropdown}</div>
))}
</div>
);

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import type { AuthUser } from '../../hooks/useAuth';
import { shortenUrl, prewarmScreenshot, paramsWithLanguage } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
@ -64,6 +64,11 @@ export const PAGE_PATHS: Record<Page, string> = {
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
export interface EditingSearchState {
id: string;
name: string;
}
export default function Header({
activePage,
activeHash,
@ -74,6 +79,9 @@ export default function Header({
dashboardParams,
onSaveSearch,
savingSearch,
editingSearch,
onCancelEdit,
onUpdateEdit,
user,
onLoginClick,
onRegisterClick,
@ -89,6 +97,9 @@ export default function Header({
dashboardParams: string;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
editingSearch: EditingSearchState | null;
onCancelEdit: () => void;
onUpdateEdit: () => void;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
@ -170,9 +181,38 @@ export default function Header({
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`;
const showEditingBar = !isMobile && editingSearch && activePage === 'dashboard';
return (
<>
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{showEditingBar && (
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-0 flex items-center justify-center px-4">
<div className="pointer-events-auto flex items-center gap-3 max-w-[60%]">
<span className="text-sm text-warm-300 truncate" title={editingSearch.name}>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{ strong: <strong className="font-semibold text-white" /> }}
/>
</span>
<button
onClick={onCancelEdit}
className="cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{t('common.cancel')}
</button>
<button
onClick={onUpdateEdit}
disabled={savingSearch}
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
</div>
)}
{/* Left: Logo + nav */}
<div className="flex items-center gap-4">
<a
@ -261,7 +301,7 @@ export default function Header({
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
)}
{onSaveSearch && (
{onSaveSearch && !editingSearch && (
<button
onClick={onSaveSearch}
disabled={savingSearch}
@ -369,6 +409,7 @@ export default function Header({
exportState={exportState}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
isEditingSearch={!!editingSearch}
user={user}
onLoginClick={onLoginClick}
onRegisterClick={onRegisterClick}

View file

@ -0,0 +1,22 @@
interface IndeterminateProgressBarProps {
show: boolean;
className?: string;
}
export function IndeterminateProgressBar({
show,
className = '',
}: IndeterminateProgressBarProps) {
if (!show) return null;
return (
<div
role="progressbar"
aria-busy="true"
aria-valuetext="loading"
className={`pointer-events-none absolute top-0 left-0 right-0 z-30 h-0.5 overflow-hidden bg-teal-500/10 dark:bg-teal-400/10 animate-fade-in ${className}`}
>
<div className="h-full w-1/4 bg-teal-500 dark:bg-teal-400 animate-indeterminate-progress" />
</div>
);
}

View file

@ -21,6 +21,7 @@ interface MobileMenuProps {
exportState: HeaderExportState | null;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
isEditingSearch: boolean;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
@ -40,6 +41,7 @@ export default function MobileMenu({
exportState,
onSaveSearch,
savingSearch,
isEditingSearch,
user,
onLoginClick,
onRegisterClick,
@ -144,7 +146,7 @@ export default function MobileMenu({
) : (
<BookmarkIcon className="w-4 h-4" />
)}
{t('common.save')}
{isEditingSearch ? t('common.update') : t('common.save')}
</button>
)}
{dashboardSavedItem}

View file

@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from 'react';
import type { ActualListing, ActualListingsResponse, Bounds } from '../types';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
const DEBOUNCE_MS = 200;
export function useActualListings(bounds: Bounds | null) {
const [listings, setListings] = useState<ActualListing[]>([]);
const [truncated, setTruncated] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const requestIdRef = useRef(0);
useEffect(() => {
requestIdRef.current += 1;
const requestId = requestIdRef.current;
if (!bounds) {
abortControllerRef.current?.abort();
if (listings.length !== 0) setListings([]);
if (truncated) setTruncated(false);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const params = new URLSearchParams({ bounds: boundsStr });
const res = await fetch(
apiUrl('actual-listings', params),
authHeaders({ signal: abortControllerRef.current.signal })
);
if (!res.ok) throw new Error(`Actual listings fetch failed: HTTP ${res.status}`);
const json: ActualListingsResponse = await res.json();
if (requestIdRef.current !== requestId) return;
setListings(json.listings || []);
setTruncated(Boolean(json.truncated));
} catch (err) {
logNonAbortError('Failed to fetch actual listings', err);
}
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort();
};
// listings/truncated intentionally excluded — they're internal state, not inputs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bounds]);
return { listings, truncated };
}

View file

@ -11,6 +11,7 @@ import type {
POI,
FeatureMeta,
Bounds,
ActualListing,
} from '../types';
import {
DENSITY_GRADIENT,
@ -21,6 +22,7 @@ import {
import { getFeatureFillColor } from '../lib/map-utils';
import type { TravelTimeEntry } from './useTravelTime';
import { usePoiLayers } from './usePoiLayers';
import { useListingLayers } from './useListingLayers';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
import { PieHexExtension } from '../lib/PieHexExtension';
@ -30,6 +32,7 @@ interface UseDeckLayersProps {
usePostcodeView: boolean;
zoom: number;
pois: POI[];
actualListings: ActualListing[];
viewFeature: string | null;
colorRange: [number, number] | null;
filterRange: [number, number] | null;
@ -71,6 +74,7 @@ export function useDeckLayers({
usePostcodeView,
zoom,
pois,
actualListings,
viewFeature,
colorRange,
filterRange,
@ -101,6 +105,15 @@ export function useDeckLayers({
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const { poiLayers, popupInfo, clearPopupInfo } = usePoiLayers({ pois, zoom, isDark });
const { listingLayers, listingPopup, clearListingPopup } = useListingLayers({
listings: actualListings,
zoom,
isDark,
hexagonData: data,
postcodeData,
resolution: usePostcodeView ? 0 : Math.round(zoom),
usePostcodeView,
});
// --- Refs for deck.gl accessors ---
const viewFeatureRef = useRef(viewFeature);
@ -606,6 +619,7 @@ export function useDeckLayers({
}
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
if (listingLayers.length > 0) baseLayers.push(...listingLayers);
return baseLayers;
}, [
usePostcodeView,
@ -616,19 +630,23 @@ export function useDeckLayers({
poiLayers,
marchingAntsLayer,
currentLocationLayer,
listingLayers,
]);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
setHoveredPostcode(null);
clearPopupInfo();
clearListingPopup();
onHexagonHoverRef.current(null);
}, [clearPopupInfo]);
}, [clearPopupInfo, clearListingPopup]);
return {
layers,
popupInfo,
clearPopupInfo,
listingPopup,
clearListingPopup,
hoverPosition,
countRange,
postcodeCountRange,

View file

@ -27,21 +27,24 @@ describe('useFilters', () => {
);
act(() => {
result.current.handleDragStart('price');
result.current.handleDragStart('price', [0, 100]);
});
expect(result.current.activeFeature).toBe('price');
expect(result.current.viewSource).toBe('drag');
expect(result.current.dragValue).toEqual([0, 100]);
expect(result.current.filterRange).toEqual([0, 100]);
act(() => {
result.current.handleDragEnd();
});
expect(result.current.activeFeature).toBeNull();
expect(result.current.dragValue).toBeNull();
expect(result.current.filters.price).toEqual([0, 100]);
act(() => {
result.current.handleDragStart('price');
result.current.handleDragStart('price', [0, 100]);
result.current.handleDragChange([10, 90]);
});
@ -55,4 +58,29 @@ describe('useFilters', () => {
expect(result.current.activeFeature).toBeNull();
expect(result.current.filters.price).toEqual([10, 90]);
});
it('uses the provided initial range for drag-only feature keys', () => {
const { result } = renderHook(() =>
useFilters({
initialFilters: {},
features,
})
);
act(() => {
result.current.handleDragStart('tt_car_station', [15, 45]);
});
expect(result.current.activeFeature).toBe('tt_car_station');
expect(result.current.dragValue).toEqual([15, 45]);
expect(result.current.filterRange).toEqual([15, 45]);
act(() => {
result.current.handleDragEnd();
});
expect(result.current.activeFeature).toBeNull();
expect(result.current.dragValue).toBeNull();
expect(result.current.filters).toEqual({});
});
});

View file

@ -416,10 +416,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleDragStart = useCallback(
(name: string) => {
(name: string, initialValue?: [number, number]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') return;
pendingDragRef.current = name;
setDragValue(initialValue ?? null);
dragValueRef.current = initialValue ?? null;
setActiveFeature(name);
},
[features]
@ -440,6 +442,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
// Click without drag — no filter value was changed, just clear preview state.
pendingDragRef.current = null;
setActiveFeature(null);
setDragValue(null);
dragValueRef.current = null;
return;
}
const af = dragActiveRef.current;
@ -458,6 +462,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
if (pendingDragRef.current) {
pendingDragRef.current = null;
setActiveFeature(null);
setDragValue(null);
dragValueRef.current = null;
return null;
}
const dv = dragValueRef.current;

View file

@ -0,0 +1,205 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import type { Layer, PickingInfo } from '@deck.gl/core';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { getResolution, latLngToCell } from 'h3-js';
import type { ActualListing, HexagonData, PostcodeFeature } from '../types';
import { trackEvent } from '../lib/analytics';
const PRICE_LABEL_MIN_ZOOM = 14;
const ADDRESS_LABEL_MIN_ZOOM = 16;
export interface ListingPopupInfo {
x: number;
y: number;
listing: ActualListing;
}
interface UseListingLayersProps {
listings: ActualListing[];
zoom: number;
isDark: boolean;
hexagonData: HexagonData[];
postcodeData: PostcodeFeature[];
usePostcodeView: boolean;
}
function normalizePostcode(value: string | undefined | null): string {
if (!value) return '';
return value.replace(/\s+/g, '').toUpperCase();
}
function formatShortPrice(price: number): string {
if (price >= 1_000_000) return `£${(price / 1_000_000).toFixed(price >= 10_000_000 ? 0 : 1)}M`;
if (price >= 1_000) return `£${Math.round(price / 1_000)}k`;
return `£${price}`;
}
export function useListingLayers({
listings,
zoom,
isDark,
hexagonData,
postcodeData,
usePostcodeView,
}: UseListingLayersProps) {
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
const visibleListings = useMemo(() => {
if (listings.length === 0) return listings;
if (usePostcodeView) {
const allowed = new Set<string>();
for (const feature of postcodeData) {
if (feature.properties.count > 0) {
allowed.add(normalizePostcode(feature.properties.postcode));
}
}
if (allowed.size === 0) return [];
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
}
const allowed = new Set<string>();
for (const cell of hexagonData) {
if (cell.count > 0) allowed.add(cell.h3);
}
if (allowed.size === 0) return [];
return listings.filter((listing) => {
try {
return allowed.has(latLngToCell(listing.lat, listing.lon, resolution));
} catch {
return false;
}
});
}, [listings, hexagonData, postcodeData, resolution, usePostcodeView]);
const handleHover = useCallback((info: PickingInfo<ActualListing>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({ x: info.x, y: info.y, listing: info.object });
} else {
setPopupInfo(null);
}
}, []);
const handleClick = useCallback((info: PickingInfo<ActualListing>) => {
const url = info.object?.listing_url;
if (!url) return;
trackEvent('Actual Listing Click', { url });
window.open(url, '_blank', 'noopener,noreferrer');
}, []);
const handleHoverRef = useRef(handleHover);
handleHoverRef.current = handleHover;
const stableHover = useCallback(
(info: PickingInfo<ActualListing>) => handleHoverRef.current(info),
[]
);
const handleClickRef = useRef(handleClick);
handleClickRef.current = handleClick;
const stableClick = useCallback(
(info: PickingInfo<ActualListing>) => handleClickRef.current(info),
[]
);
const pinShadowLayer = useMemo(
() =>
new ScatterplotLayer<ActualListing>({
id: 'actual-listing-shadow',
data: visibleListings,
getPosition: (d) => [d.lon, d.lat],
getRadius: 8,
radiusUnits: 'pixels',
getFillColor: isDark ? [0, 0, 0, 80] : [0, 0, 0, 40],
pickable: false,
}),
[visibleListings, isDark]
);
const pinLayer = useMemo(
() =>
new ScatterplotLayer<ActualListing>({
id: 'actual-listing-pin',
data: visibleListings,
getPosition: (d) => [d.lon, d.lat],
getRadius: 7,
radiusUnits: 'pixels',
getFillColor: [231, 76, 60, 240],
getLineColor: [255, 255, 255, 255],
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
stroked: true,
pickable: true,
autoHighlight: true,
highlightColor: [29, 228, 195, 220],
onHover: stableHover,
onClick: stableClick,
}),
[visibleListings, stableHover, stableClick]
);
const priceLabelLayer = useMemo(() => {
if (zoom < PRICE_LABEL_MIN_ZOOM) return null;
const labeled = visibleListings.filter((l) => l.asking_price && l.asking_price > 0);
return new TextLayer<ActualListing>({
id: 'actual-listing-price',
data: labeled,
getPosition: (d) => [d.lon, d.lat],
getText: (d) => formatShortPrice(d.asking_price ?? 0),
getSize: 12,
getPixelOffset: [0, -16],
getColor: isDark ? [255, 255, 255, 240] : [30, 30, 30, 240],
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 700,
getTextAnchor: 'middle',
getAlignmentBaseline: 'bottom',
outlineWidth: 3,
outlineColor: isDark ? [10, 10, 10, 220] : [255, 255, 255, 230],
fontSettings: { sdf: true },
sizeUnits: 'pixels',
sizeMinPixels: 10,
sizeMaxPixels: 14,
pickable: false,
});
}, [visibleListings, zoom, isDark]);
const detailLabelLayer = useMemo(() => {
if (zoom < ADDRESS_LABEL_MIN_ZOOM) return null;
const labeled = visibleListings.filter((l) => l.address || l.bedrooms != null);
return new TextLayer<ActualListing>({
id: 'actual-listing-detail',
data: labeled,
getPosition: (d) => [d.lon, d.lat],
getText: (d) => {
const parts: string[] = [];
if (d.bedrooms != null) parts.push(`${d.bedrooms} bed`);
if (d.property_sub_type) parts.push(d.property_sub_type);
else if (d.property_type) parts.push(d.property_type);
return parts.join(' · ');
},
getSize: 10,
getPixelOffset: [0, 14],
getColor: isDark ? [220, 220, 220, 230] : [60, 60, 60, 230],
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 500,
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
outlineWidth: 3,
outlineColor: isDark ? [10, 10, 10, 220] : [255, 255, 255, 230],
fontSettings: { sdf: true },
sizeUnits: 'pixels',
sizeMinPixels: 9,
sizeMaxPixels: 12,
pickable: false,
});
}, [visibleListings, zoom, isDark]);
const listingLayers = useMemo(() => {
const layers: Layer[] = [pinShadowLayer, pinLayer];
if (priceLabelLayer) layers.push(priceLabelLayer);
if (detailLabelLayer) layers.push(detailLabelLayer);
return layers;
}, [pinShadowLayer, pinLayer, priceLabelLayer, detailLabelLayer]);
const clearListingPopup = useCallback(() => setPopupInfo(null), []);
return { listingLayers, listingPopup: popupInfo, clearListingPopup };
}

View file

@ -126,7 +126,7 @@ describe('useMapData', () => {
});
});
it('resets the colour range to drag preview data while a slider is active', async () => {
it('resets the colour range to visible drag preview data while a slider is active', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
{
@ -139,16 +139,28 @@ describe('useMapData', () => {
const filters = { price: [20, 80] as [number, number] };
const { result, rerender } = renderHook(
({ activeFeature }: { activeFeature: string | null }) =>
({
activeFeature,
filterRange,
}: {
activeFeature: string | null;
filterRange: [number, number] | null;
}) =>
useMapData({
filters,
features,
viewFeature: 'price',
activeFeature,
pinnedFeature: null,
filterRange,
travelTimeEntries: noTravelTimeEntries,
}),
{ initialProps: { activeFeature: null as string | null } }
{
initialProps: {
activeFeature: null as string | null,
filterRange: filters.price,
},
}
);
await act(async () => {
@ -171,11 +183,122 @@ describe('useMapData', () => {
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
await act(async () => {
rerender({ activeFeature: 'price' });
rerender({ activeFeature: 'price', filterRange: filters.price });
await flushPromises();
});
expect(requests).toHaveLength(2);
const previewData = [
{
h3: 'preview-outside-low',
count: 1,
lat: 1.1,
lon: 1.1,
min_price: 0,
max_price: 10,
avg_price: 5,
},
{
h3: 'preview-low',
count: 1,
lat: 1.25,
lon: 1.25,
min_price: 20,
max_price: 20,
avg_price: 20,
},
{
h3: 'preview-high',
count: 1,
lat: 1.75,
lon: 1.75,
min_price: 80,
max_price: 80,
avg_price: 80,
},
{
h3: 'preview-outside-high',
count: 1,
lat: 1.9,
lon: 1.9,
min_price: 90,
max_price: 100,
avg_price: 95,
},
];
await act(async () => {
requests[1].resolve(response(previewData));
await flushPromises();
});
expect(result.current.data).toEqual(previewData);
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
});
it('does not use metadata min/max while slider preview colour data is loading', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
{
name: 'price',
type: 'numeric',
min: 0,
max: 100,
},
];
const filters = { price: [20, 80] as [number, number] };
const { result, rerender } = renderHook(
({
viewFeature,
activeFeature,
}: {
viewFeature: string | null;
activeFeature: string | null;
}) =>
useMapData({
filters,
features,
viewFeature,
activeFeature,
pinnedFeature: null,
travelTimeEntries: noTravelTimeEntries,
}),
{
initialProps: {
viewFeature: null as string | null,
activeFeature: null as string | null,
},
}
);
await act(async () => {
result.current.handleViewChange(viewChange(bounds));
});
await act(async () => {
vi.advanceTimersByTime(150);
});
await act(async () => {
requests[0].resolve(
response([
{ h3: 'density-low', count: 1, lat: 1.25, lon: 1.25 },
{ h3: 'density-high', count: 1, lat: 1.75, lon: 1.75 },
])
);
await flushPromises();
});
await act(async () => {
rerender({
viewFeature: 'price',
activeFeature: 'price',
});
await flushPromises();
});
expect(result.current.colorRange).toBeNull();
await act(async () => {
requests[1].resolve(
response([
@ -186,14 +309,86 @@ describe('useMapData', () => {
await flushPromises();
});
expect(result.current.data).toEqual([
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
]);
expect(result.current.colorRange?.[0]).toBeCloseTo(5);
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
});
it('does not use stale committed feature data while slider preview colour data is loading', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
{
name: 'price',
type: 'numeric',
min: 0,
max: 1_000,
},
];
const { result, rerender } = renderHook(
({
filters,
activeFeature,
}: {
filters: Record<string, [number, number]>;
activeFeature: string | null;
}) =>
useMapData({
filters,
features,
viewFeature: 'price',
activeFeature,
pinnedFeature: null,
travelTimeEntries: noTravelTimeEntries,
}),
{
initialProps: {
filters: { price: [0, 1_000] as [number, number] },
activeFeature: null as string | null,
},
}
);
await act(async () => {
result.current.handleViewChange(viewChange(bounds));
});
await act(async () => {
vi.advanceTimersByTime(150);
});
await act(async () => {
requests[0].resolve(
response([
{ h3: 'stale-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'stale-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 1_000 },
])
);
await flushPromises();
});
expect(result.current.colorRange?.[1]).toBeCloseTo(950);
await act(async () => {
rerender({
filters: { price: [20, 80] },
activeFeature: 'price',
});
await flushPromises();
});
expect(result.current.colorRange).toBeNull();
await act(async () => {
requests[1].resolve(
response([
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
])
);
await flushPromises();
});
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
});
it('does not reuse cached drag preview data when the drag request changes', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [

View file

@ -24,6 +24,7 @@ import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { type TravelTimeEntry } from './useTravelTime';
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
@ -44,18 +45,38 @@ interface UseMapDataOptions {
viewFeature: string | null;
activeFeature: string | null;
pinnedFeature: string | null;
filterRange?: [number, number] | null;
travelTimeEntries: TravelTimeEntry[];
/** Share-link code from the URL; appended to data fetches so the backend
* grants bbox-scoped access for unlicensed recipients. */
shareCode?: string;
}
function getFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function valueInVisibleRange(
value: number,
minValue: number | null,
maxValue: number | null,
visibleRange: [number, number] | null
): number | null {
if (!visibleRange) return value;
const itemMin = minValue ?? value;
const itemMax = maxValue ?? value;
if (itemMax < visibleRange[0] || itemMin > visibleRange[1]) return null;
return Math.max(visibleRange[0], Math.min(visibleRange[1], value));
}
export function useMapData({
filters,
features,
viewFeature,
activeFeature,
pinnedFeature,
filterRange = null,
travelTimeEntries,
shareCode,
}: UseMapDataOptions) {
@ -70,12 +91,18 @@ export function useMapData({
longitude: number;
zoom: number;
} | null>(null);
const [currentVisibleView, setCurrentVisibleView] = useState<{
latitude: number;
longitude: number;
zoom: number;
} | null>(null);
const [licenseRequired, setLicenseRequired] = useState(false);
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
// Drag preview state
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
const [dragDataKey, setDragDataKey] = useState<string>('');
const dragFeatureRef = useRef<string | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const activeFeatureRef = useRef<string | null>(null);
@ -119,32 +146,19 @@ export function useMapData({
);
const filtersParam = useMemo(() => buildFilterParam(), [buildFilterParam]);
// Build the travel param string from entries with destinations.
// Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
// When excludeFieldKey is set, that entry uses a wide range (0:1440) instead of
// the committed range. This still filters out rows with no travel data (the server
// skips rows where minutes=None when any range is set) while including all actual values.
// Format: mode:slug[:best][:min:max]. For drag preview, the active travel
// filter uses an unbounded range so rows with travel data stay visible.
const buildTravelParam = useCallback(
(excludeFieldKey?: string): string => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best';
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
if (isExcluded) {
seg += ':0:1440';
} else if (entry.timeRange) {
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
segments.push(seg);
}
return segments.join('|');
},
(excludeFieldKey?: string): string =>
serializeTravelParam(travelTimeEntries, excludeFieldKey, true),
[travelTimeEntries]
);
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
const filterStateKey = useMemo(
() => `${filtersParam}|${travelParam}`,
[filtersParam, travelParam]
);
const boundsParam = useMemo(
() => (bounds ? `${bounds.south},${bounds.west},${bounds.north},${bounds.east}` : ''),
[bounds]
@ -176,28 +190,13 @@ export function useMapData({
]
);
const [loadedDataKey, setLoadedDataKey] = useState<string>('');
// Keep activeFeatureRef in sync
useEffect(() => {
activeFeatureRef.current = activeFeature;
}, [activeFeature]);
useEffect(() => {
if (activeFeature) return;
latestDragRequestKeyRef.current = '';
dragFeatureRef.current = null;
setDragHexData(null);
setDragPostcodeData(null);
}, [activeFeature]);
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
// For regular filters: excludes the filter from the filter string.
// For travel time: excludes the time range from that entry's travel param segment.
useEffect(() => {
if (!activeFeature || !bounds) return;
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
const previousDragStateRef = useRef<{ activeFeature: string | null; filterStateKey: string }>({
activeFeature: null,
filterStateKey,
});
const resetPreviewScaleAfterSliderRef = useRef(false);
const activeDragRequest = useMemo(() => {
if (!activeFeature || !bounds) return null;
const filtersStr = buildFilterString(filters, features, activeFeature);
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
@ -218,7 +217,58 @@ export function useMapData({
viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
shareCode ?? '',
].join('|');
return { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey };
}, [
activeFeature,
bounds,
buildTravelParam,
dataViewFeature,
features,
filters,
getBackendFeatureName,
resolution,
shareCode,
travelParam,
usePostcodeView,
viewFeatureIsEnum,
]);
// Keep activeFeatureRef in sync
useEffect(() => {
activeFeatureRef.current = activeFeature;
}, [activeFeature]);
useEffect(() => {
const previous = previousDragStateRef.current;
if (!activeFeature && previous.activeFeature && previous.filterStateKey !== filterStateKey) {
resetPreviewScaleAfterSliderRef.current = true;
}
previousDragStateRef.current = { activeFeature, filterStateKey };
}, [activeFeature, filterStateKey]);
useEffect(() => {
if (activeFeature) return;
latestDragRequestKeyRef.current = '';
dragFeatureRef.current = null;
setDragDataKey('');
setDragHexData(null);
setDragPostcodeData(null);
}, [activeFeature]);
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
// For regular filters: excludes the filter from the filter string.
// For travel time: excludes the time range from that entry's travel param segment.
useEffect(() => {
if (!activeFeature || !activeDragRequest) return;
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
latestDragRequestKeyRef.current = requestKey;
setDragDataKey('');
dragFeatureRef.current = null;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
@ -234,6 +284,7 @@ export function useMapData({
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragPostcodeData(json.features);
setDragHexData(null);
setDragDataKey(requestKey);
dragFeatureRef.current = activeFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
@ -254,6 +305,7 @@ export function useMapData({
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragHexData(json.features);
setDragPostcodeData(null);
setDragDataKey(requestKey);
dragFeatureRef.current = activeFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
@ -270,15 +322,9 @@ export function useMapData({
};
}, [
activeFeature,
bounds,
resolution,
filters,
features,
usePostcodeView,
travelParam,
buildTravelParam,
activeDragRequest,
dataViewFeature,
getBackendFeatureName,
usePostcodeView,
viewFeatureIsEnum,
shareCode,
]);
@ -386,6 +432,7 @@ export function useMapData({
if (!activeFeatureRef.current) {
setDragHexData(null);
setDragPostcodeData(null);
setDragDataKey('');
dragFeatureRef.current = null;
}
setLoading(false);
@ -420,19 +467,27 @@ export function useMapData({
shareCode,
]);
// Use drag data when it matches the current view feature, otherwise fall back to rawData
const data =
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ??
rawData;
const effectivePostcodeData =
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature
? dragPostcodeData
: null) ?? postcodeData;
// Use drag data only when it matches the current view feature and request key.
// The first render of a new drag must not reuse the previous drag's preview range.
const dragJustStarted =
Boolean(activeFeature) &&
(previousDragStateRef.current.activeFeature !== activeFeature ||
previousDragStateRef.current.filterStateKey !== filterStateKey);
const hasMatchingDragData =
!dragJustStarted &&
Boolean(activeFeature && viewFeature && activeDragRequest) &&
dragFeatureRef.current === viewFeature &&
dragDataKey === activeDragRequest?.requestKey;
const hasCurrentRangeData = activeFeature
? hasMatchingDragData
: loadedDataKey === dataRequestKey;
const data = (hasMatchingDragData ? dragHexData : null) ?? rawData;
const effectivePostcodeData = (hasMatchingDragData ? dragPostcodeData : null) ?? postcodeData;
// Compute p5/p95 from committed data for the viewed feature.
// Always uses rawData/postcodeData (not drag preview data) so the color
// scale stays stable while dragging a filter slider.
// Compute p5/p95 from the data currently being drawn. During slider drags
// this uses the drag-preview data so the colour scale resets to that preview.
const dataRange = useMemo((): [number, number] | null => {
if (!hasCurrentRangeData) return null;
if (!dataViewFeature) return null;
const isTravelTime = dataViewFeature.startsWith('tt_');
@ -445,26 +500,40 @@ export function useMapData({
const vals: number[] = [];
if (usePostcodeView) {
if (postcodeData.length === 0) return null;
for (const feat of postcodeData) {
if (effectivePostcodeData.length === 0) return null;
for (const feat of effectivePostcodeData) {
if (bounds) {
const [lng, lat] = feat.properties.centroid as [number, number];
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
continue;
}
const val = feat.properties[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
const val = getFiniteNumber(feat.properties[`avg_${dataViewFeature}`]);
if (val == null) continue;
const visibleValue = valueInVisibleRange(
val,
getFiniteNumber(feat.properties[`min_${dataViewFeature}`]),
getFiniteNumber(feat.properties[`max_${dataViewFeature}`]),
filterRange
);
if (visibleValue != null) vals.push(visibleValue);
}
} else {
if (rawData.length === 0) return null;
for (const item of rawData) {
if (data.length === 0) return null;
for (const item of data) {
if (bounds) {
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
const val = getFiniteNumber(item[`avg_${dataViewFeature}`]);
if (val == null) continue;
const visibleValue = valueInVisibleRange(
val,
getFiniteNumber(item[`min_${dataViewFeature}`]),
getFiniteNumber(item[`max_${dataViewFeature}`]),
filterRange
);
if (visibleValue != null) vals.push(visibleValue);
}
}
@ -474,7 +543,16 @@ export function useMapData({
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
}, [
bounds,
data,
dataViewFeature,
effectivePostcodeData,
features,
filterRange,
hasCurrentRangeData,
usePostcodeView,
]);
// Live color range for the legend and hex coloring.
const liveColorRange = useMemo((): [number, number] | null => {
@ -491,9 +569,11 @@ export function useMapData({
return [0, meta.values.length - 1];
}
if (dataRange) return dataRange;
if (activeFeature) return null;
if (loadedDataKey !== dataRequestKey) return null;
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null;
}, [dataViewFeature, features, dataRange]);
}, [activeFeature, dataRequestKey, dataRange, dataViewFeature, features, loadedDataKey]);
const isEyePreviewingPinnedFeature =
!activeFeature && dataViewFeature != null && dataViewFeature === pinnedDataViewFeature;
@ -505,7 +585,10 @@ export function useMapData({
useEffect(() => {
setFrozenPreviewRange((prev) => {
if (!pinnedDataViewFeature) return prev ? null : prev;
if (!pinnedDataViewFeature) {
resetPreviewScaleAfterSliderRef.current = false;
return prev ? null : prev;
}
return prev?.feature === pinnedDataViewFeature ? prev : null;
});
}, [pinnedDataViewFeature]);
@ -524,11 +607,13 @@ export function useMapData({
: null;
if (!rangeToFreeze) return;
const resetAfterSlider = resetPreviewScaleAfterSliderRef.current;
setFrozenPreviewRange((prev) =>
prev?.feature === pinnedDataViewFeature
? prev
: { feature: pinnedDataViewFeature, range: rangeToFreeze }
resetAfterSlider || prev?.feature !== pinnedDataViewFeature
? { feature: pinnedDataViewFeature, range: rangeToFreeze }
: prev
);
if (resetAfterSlider) resetPreviewScaleAfterSliderRef.current = false;
}, [
dataRange,
dataRequestKey,
@ -583,6 +668,8 @@ export function useMapData({
zoom: newZoom,
latitude,
longitude,
visibleLatitude,
visibleLongitude,
}: ViewChangeParams) => {
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
if (boundsKey !== prevBoundsRef.current) {
@ -592,6 +679,11 @@ export function useMapData({
}
setZoom(newZoom);
setCurrentView({ latitude, longitude, zoom: newZoom });
setCurrentVisibleView({
latitude: visibleLatitude ?? latitude,
longitude: visibleLongitude ?? longitude,
zoom: newZoom,
});
},
[]
);
@ -599,20 +691,28 @@ export function useMapData({
const setInitialView = useCallback(
(view: { latitude: number; longitude: number; zoom: number }) => {
setCurrentView(view);
setCurrentVisibleView(view);
setZoom(view.zoom);
},
[]
);
// Treat the map as loading whenever the rendered hexagons don't match the
// current request — covers the brief window between a slider release and
// the main fetch effect actually firing setLoading(true).
const isLoading =
loading || (bounds != null && !licenseRequired && loadedDataKey !== dataRequestKey);
return {
data,
committedHexagonData: rawData,
postcodeData: effectivePostcodeData,
resolution,
bounds,
loading,
loading: isLoading,
zoom,
currentView,
currentVisibleView,
usePostcodeView,
colorRange,
canResetPreviewScale,

View file

@ -176,6 +176,46 @@ export function useSavedSearches(userId: string | null) {
}
}, []);
const updateSearchParams = useCallback(
async (id: string, params: string) => {
if (!userId) return;
setSaving(true);
setError(null);
try {
const record = await pb.collection('saved_searches').update(id, { params });
trackEvent('Search Update');
setSearches((prev) =>
prev.map((s) => (s.id === id ? { ...s, params, screenshotUrl: '' } : s))
);
// Refresh screenshot in the background
const screenshotParams = new URLSearchParams(params);
const screenshotUrl = apiUrl('screenshot', screenshotParams);
fetch(screenshotUrl, authHeaders())
.then((res) => {
if (!res.ok) throw new Error(`Screenshot ${res.status}`);
return res.blob();
})
.then((blob) => {
const patch = new FormData();
patch.append('screenshot', blob, 'screenshot.jpg');
return pb.collection('saved_searches').update(record.id, patch);
})
.then(() => fetchSearches())
.catch((err) => {
console.warn('Background screenshot failed:', err);
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update search';
setError(msg);
throw err;
} finally {
setSaving(false);
}
},
[userId, fetchSearches]
);
return {
searches,
loading,
@ -186,5 +226,6 @@ export function useSavedSearches(userId: string | null) {
deleteSearch,
updateSearchNotes,
updateSearchName,
updateSearchParams,
};
}

View file

@ -65,4 +65,51 @@ describe('useTravelTime', () => {
expect(result.current.entries).toEqual([replacement]);
expect(result.current.activeEntries).toEqual([replacement]);
});
it('deduplicates initial and replacement entries using the tightest range', () => {
const wide: TravelTimeEntry = {
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [0, 60],
useBest: false,
};
const tight: TravelTimeEntry = {
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [10, 45],
useBest: false,
};
const replacement: TravelTimeEntry = {
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [20, 40],
useBest: true,
};
const { result } = renderHook(() => useTravelTime({ entries: [wide, tight] }));
expect(result.current.entries).toEqual([
{
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [10, 45],
useBest: false,
},
]);
act(() => result.current.handleSetEntries([wide, replacement]));
expect(result.current.entries).toEqual([
{
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [20, 40],
useBest: true,
},
]);
});
});

View file

@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react';
import type { ComponentType } from 'react';
import { useTranslation } from 'react-i18next';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon } from '../components/ui/icons';
import { dedupeTravelTimeEntries } from '../lib/travel-params';
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
@ -75,7 +76,9 @@ export interface TravelTimeInitial {
}
export function useTravelTime(initial?: TravelTimeInitial) {
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
const [entries, setEntries] = useState<TravelTimeEntry[]>(() =>
dedupeTravelTimeEntries(initial?.entries ?? [])
);
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [...prev, { mode, slug: '', label: '', timeRange: null, useBest: false }]);
@ -87,26 +90,32 @@ export function useTravelTime(initial?: TravelTimeInitial) {
const handleSetDestination = useCallback((index: number, slug: string, label: string) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
dedupeTravelTimeEntries(
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
)
)
);
}, []);
const handleTimeRangeChange = useCallback((index: number, range: [number, number]) => {
setEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
dedupeTravelTimeEntries(
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
)
);
}, []);
const handleToggleBest = useCallback((index: number) => {
setEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
dedupeTravelTimeEntries(
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
)
);
}, []);
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
setEntries(newEntries);
setEntries(dedupeTravelTimeEntries(newEntries));
}, []);
/** Entries that have a destination selected (slug is set) */

View file

@ -170,7 +170,7 @@ export const details: Record<string, Record<string, string>> = {
'Number of bedrooms & living rooms':
'Gesamtanzahl der Wohnräume (Schlaf- und Wohnzimmer), wie im Energieausweis-Zertifikat erfasst. Küchen und Badezimmer sind in der Regel ausgeschlossen, sofern sie nicht groß genug sind, um als Wohnräume zu gelten.',
'Construction year':
'Abgeleitet aus dem Baualtersband im EPC (z. B. „19301949") durch Verwendung des Mittelpunkts. Bei älteren Gebäuden, bei denen das Altersband mehrere Jahrzehnte umfasst, weniger präzise.',
'Abgeleitet aus dem Baualtersband im EPC (z. B. „19301949) durch Verwendung des Mittelpunkts. Bei älteren Gebäuden, bei denen das Altersband mehrere Jahrzehnte umfasst, weniger präzise.',
'Date of last transaction':
'Das Datum des zuletzt erfassten Verkaufs dieser Immobilie aus den HM Land Registry Price Paid-Daten. In den Daten als Datum-/Uhrzeitangabe gespeichert; für Filterung und Diagramme in ein Dezimaljahr umgerechnet.',
'Former council house':
@ -232,7 +232,7 @@ export const details: Record<string, Record<string, string>> = {
'Criminal damage and arson (avg/yr)':
'Durchschnittliche Anzahl von Sachbeschädigungs- und Brandstiftungsvorfällen pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene.',
'Other theft (avg/yr)':
'Durchschnittliche Anzahl von „sonstigen Diebstählen" pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene. Umfasst Diebstähle, die nicht unter Einbruch, Fahrzeugkriminalität, Ladendiebstahl oder Fahrraddiebstahl eingestuft sind.',
'Durchschnittliche Anzahl von „sonstigen Diebstählen pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene. Umfasst Diebstähle, die nicht unter Einbruch, Fahrzeugkriminalität, Ladendiebstahl oder Fahrraddiebstahl eingestuft sind.',
'Theft from the person (avg/yr)':
'Durchschnittliche Anzahl von Taschendiebstählen und ähnlichen Delikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene. Umfasst Taschendiebstahl und Handtaschenraub ohne Gewaltanwendung.',
'Shoplifting (avg/yr)':
@ -316,7 +316,7 @@ export const details: Record<string, Record<string, string>> = {
'Number of bedrooms & living rooms':
'EPC中记录的可居住房间总数卧室加客厅。厨房和浴室通常不计入除非面积足够大可算作可居住房间。',
'Construction year':
'根据EPC中的建造年代段例如"1930-1949")取中间值推算。对于年代段跨越数十年的老建筑,精度较低。',
'根据EPC中的建造年代段例如“1930-1949”)取中间值推算。对于年代段跨越数十年的老建筑,精度较低。',
'Date of last transaction':
'来自英国土地注册局价格数据中该房产最近一次成交的记录日期。数据中以日期时间格式存储;在筛选和图表中转换为小数年份。',
'Former council house':
@ -378,7 +378,7 @@ export const details: Record<string, Record<string, string>> = {
'Criminal damage and arson (avg/yr)':
'LSOA内每年刑事损毁和纵火事件的平均数量来自police.uk街道级犯罪数据。',
'Other theft (avg/yr)':
'LSOA内每年"其他盗窃"案的平均数量来自police.uk街道级犯罪数据。包括未被归类为入室盗窃、车辆犯罪、商店行窃或自行车盗窃的盗窃行为。',
'LSOA内每年“其他盗窃”案的平均数量来自police.uk街道级犯罪数据。包括未被归类为入室盗窃、车辆犯罪、商店行窃或自行车盗窃的盗窃行为。',
'Theft from the person (avg/yr)':
'LSOA内每年针对人身盗窃案的平均数量来自police.uk街道级犯罪数据。包括扒窃和未使用暴力的抢包行为。',
'Shoplifting (avg/yr)': 'LSOA内每年商店行窃案的平均数量来自police.uk街道级犯罪数据。',
@ -466,7 +466,7 @@ export const details: Record<string, Record<string, string>> = {
'Interior height (m)':
'EPC आकलन के दौरान दर्ज औसत अंदरूनी फर्श-से-छत ऊंचाई, मीटर में. कुल आंतरिक आयतन को कुल फर्श क्षेत्र से भाग देकर निकाली जाती है.',
'Street tree density percentile':
'Forest Research के 2025 Trees Outside Woodland नक्शे से निकाला गया पोस्टकोड केंद्र के आसपास का अनुमानित वृक्ष आच्छादन. अकेले पेड़ों और पेड़ों के समूहों के canopy polygons को हर पोस्टकोड केंद्र से 50m के भीतर गिना जाता है, फिर इंग्लैंड के पोस्टकोडों के मुकाबले प्रतिशतक में बदला जाता है. यह पोस्टकोड-केंद्र proxy है, किसी संपत्ति या सड़क-खंड की सटीक माप नहीं.',
'Forest Research के 2025 Trees Outside Woodland नक्शे से निकाला गया पोस्टकोड केंद्र के आसपास का अनुमानित वृक्ष आच्छादन. अकेले पेड़ों और पेड़ों के समूहों के वृक्ष-शिखर बहुभुजों को हर पोस्टकोड केंद्र से 50m के भीतर गिना जाता है, फिर इंग्लैंड के पोस्टकोडों के मुकाबले प्रतिशतक में बदला जाता है. यह पोस्टकोड-केंद्र पर आधारित अनुमानक है, किसी संपत्ति या सड़क-खंड की सटीक माप नहीं.',
'Good+ primary schools within 2km':
'2 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Good+ secondary schools within 2km':
@ -714,7 +714,7 @@ export const details: Record<string, Record<string, string>> = {
Schools:
'A közeli állami finanszírozású általános vagy középiskolákat szűri a kiválasztott Ofsted minősítés és távolság alapján. Az elérhető küszöbök általában a 2 km-en vagy 5 km-en belüli Jó vagy Kiemelkedő, illetve csak Kiemelkedő iskolákat fedik le.',
'Specific crimes':
'Egyszerre egy utcai bűncselekmény-kategóriát szűr az LSOA éves átlagos esetszámai alapján. Az értékek a-ös police.uk adatokból származnak, és segítenek külön vizsgálni például a betörést, járműbűnözést vagy antiszociális viselkedést.',
'Egyszerre egy utcai bűncselekmény-kategóriát szűr az LSOA éves átlagos esetszámai alapján. Az értékek a police.uk adataiból származnak, és segítenek külön vizsgálni például a betörést, járműbűnözést vagy antiszociális viselkedést.',
Ethnicities:
'A kiválasztott etnikai csoport népességi arányát szűri a 2021-es népszámlálás alapján. Egyszerre egy kategória alkalmazható, hogy a helyi összetétel összehasonlítható legyen a területek között.',
'Amenity distance':

View file

@ -4,6 +4,7 @@ const de: Translations = {
// ── Common ──────────────────────────────────────────
common: {
save: 'Speichern',
update: 'Aktualisieren',
cancel: 'Abbrechen',
close: 'Schließen',
delete: 'Löschen',
@ -122,27 +123,29 @@ const de: Translations = {
'Start with a maximum price and property type, then colour the map by price per square metre or estimated current price. This helps reveal areas where similar homes have historically traded within reach, even when there are no live listings today.':
'Beginnen Sie mit einem Höchstpreis und einem Immobilientyp und färben Sie die Karte dann nach dem Preis pro Quadratmeter oder dem geschätzten aktuellen Preis ein. Dies hilft dabei, Bereiche aufzudecken, in denen in der Vergangenheit ähnliche Häuser in Reichweite gehandelt wurden, auch wenn es heute keine Live-Einträge gibt.',
'Filter by last known sale price, estimated current value, property type, tenure, and floor area.':
'Filtern Sie nach dem letzten bekannten Verkaufspreis, dem geschätzten aktuellen Wert, der Art der Immobilie, der Nutzungsdauer und der Grundfläche.',
'Filtern Sie nach dem letzten bekannten Verkaufspreis, dem geschätzten aktuellen Wert, der Immobilienart, der Eigentumsform und der Wohnfläche.',
'Compare nearby postcodes using the same criteria instead of relying on area reputation.':
'Vergleichen Sie nahegelegene Postleitzahlen anhand derselben Kriterien, anstatt sich auf die Reputation der Region zu verlassen.',
'Use the results as a shortlist for listing alerts, local research, and viewings.':
'Verwenden Sie die Ergebnisse als Auswahlliste für die Auflistung von Benachrichtigungen, lokale Recherchen und Besichtigungen.',
'Separate cheap from good value': 'Trennen Sie günstig vom guten Preis-Leistungs-Verhältnis',
'Verwenden Sie die Ergebnisse als Auswahlliste für Angebotsbenachrichtigungen, lokale Recherchen und Besichtigungen.',
'Separate cheap from good value':
'Unterscheiden Sie günstig von gutem Preis-Leistungs-Verhältnis',
'A lower price can reflect smaller homes, weaker transport, more noise, or fewer local services. The map keeps those trade-offs visible so the cheapest postcode isnt automatically treated as the best option.':
'Ein niedrigerer Preis kann auf kleinere Häuser, schwächere Transportmöglichkeiten, mehr Lärm oder weniger lokale Dienstleistungen zurückzuführen sein. Die Karte macht diese Kompromisse sichtbar, sodass die günstigste Postleitzahl nicht automatisch als beste Option behandelt wird.',
'Start from area value, not listing availability':
'Beginnen Sie mit dem Flächenwert und geben Sie nicht die Verfügbarkeit an',
'Beginnen Sie mit dem Wert eines Gebiets, nicht mit der Verfügbarkeit von Angeboten',
'Listing portals only show homes for sale today. A postcode-level property price map lets you compare wider areas, understand local price patterns, and avoid missing places where the next suitable listing might appear.':
'Auf Immobilienportalen werden heute ausschließlich Häuser zum Verkauf angeboten. Mit einer Immobilienpreiskarte auf Postleitzahlenebene können Sie größere Gebiete vergleichen, lokale Preismuster verstehen und vermeiden, Orte zu verpassen, an denen das nächste passende Angebot erscheinen könnte.',
'Use prices alongside real constraints': 'Nutzen Sie Preise neben realen Zwängen',
'Immobilienportale zeigen nur Häuser, die heute zum Verkauf stehen. Eine Immobilienpreiskarte auf Postleitzahlenebene ermöglicht es Ihnen, größere Gebiete zu vergleichen, lokale Preismuster zu verstehen und keine Orte zu verpassen, an denen das nächste passende Angebot erscheinen könnte.',
'Use prices alongside real constraints':
'Nutzen Sie Preise zusammen mit realen Anforderungen',
'Budget rarely matters on its own. Perfect Postcode combines price filters with travel time, school quality, property size, energy performance, local environment, and services so your shortlist reflects how you actually want to live.':
'Das Budget allein spielt selten eine Rolle. Perfect Postcode kombiniert Preisfilter mit Reisezeit, Schulqualität, Grundstücksgröße, Energieleistung, lokaler Umgebung und Dienstleistungen, sodass Ihre Auswahlliste widerspiegelt, wie Sie tatsächlich leben möchten.',
'Das Budget allein zählt selten. Perfect Postcode kombiniert Preisfilter mit Reisezeit, Schulqualität, Wohnfläche, Energieeffizienz, lokaler Umgebung und Dienstleistungen, sodass Ihre Auswahlliste widerspiegelt, wie Sie tatsächlich leben möchten.',
'What the price data is for': 'Wozu dienen die Preisdaten?',
'Use the map to compare areas and spot search candidates. It isnt a valuation, mortgage decision, survey, legal search, or live listing feed.':
'Nutzen Sie die Karte, um Gebiete zu vergleichen und Suchkandidaten zu erkennen. Es handelt sich nicht um eine Bewertung, eine Hypothekenentscheidung, eine Umfrage, eine rechtliche Suche oder einen Live-Eintrags-Feed.',
'How to validate a promising area': 'So validieren Sie einen vielversprechenden Bereich',
'Nutzen Sie die Karte, um Gebiete zu vergleichen und Suchkandidaten zu erkennen. Es handelt sich nicht um eine Wertermittlung, eine Hypothekenentscheidung, ein Gutachten, eine rechtliche Prüfung oder einen Live-Angebots-Feed.',
'How to validate a promising area': 'So validieren Sie ein vielversprechendes Gebiet',
'Once a postcode looks promising, check current listings, sold-price comparables, agent details, flood searches, legal packs, surveys, and local authority information before making a decision.':
'Sobald eine Postleitzahl vielversprechend aussieht, prüfen Sie aktuelle Angebote, Vergleichswerte zu Verkaufspreisen, Maklerdetails, Überschwemmungssuchen, Rechtsbeilagen, Umfragen und Informationen der örtlichen Behörden, bevor Sie eine Entscheidung treffen.',
'Sobald eine Postleitzahl vielversprechend aussieht, prüfen Sie aktuelle Angebote, Vergleichswerte zu Verkaufspreisen, Maklerdetails, Hochwasser-Recherchen, rechtliche Unterlagen, Gutachten und Informationen der örtlichen Behörden, bevor Sie eine Entscheidung treffen.',
'Is this a replacement for Rightmove or Zoopla?':
'Ist dies ein Ersatz für Rightmove oder Zoopla?',
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show whats currently for sale.':
@ -153,7 +156,7 @@ const de: Translations = {
'Ja. Preisfilter können mit Reisezeit-, Schul-, Kriminalitäts-, Breitband-, Straßenlärm-, Ausstattungs- und Umgebungsfiltern kombiniert werden.',
'Does the map cover all of the UK?': 'Deckt die Karte ganz Großbritannien ab?',
'The current product focuses on England because several core property and postcode datasets are England-specific.':
'Das aktuelle Produkt konzentriert sich auf England, da mehrere Kerndatensätze zu Grundstücken und Postleitzahlen spezifisch für England sind.',
'Das aktuelle Produkt konzentriert sich auf England, da mehrere zentrale Datensätze zu Immobilien und Postleitzahlen spezifisch für England sind.',
'Birmingham property search guide': 'Leitfaden für die Immobiliensuche in Birmingham',
'A worked example for balancing price, commute, and family trade-offs.':
'Ein praktisches Beispiel für den Ausgleich von Preis-, Pendel- und Familienkompromissen.',
@ -173,19 +176,19 @@ const de: Translations = {
'Postcode property search - Find areas that match your criteria':
'Immobiliensuche nach Postleitzahlen Finden Sie Gebiete, die Ihren Kriterien entsprechen',
'Search every postcode by budget, property type, floor area, tenure, commute, schools, crime, broadband, noise, parks and local amenities.':
'Durchsuchen Sie jede Postleitzahl nach Budget, Immobilientyp, Grundfläche, Besitz, Pendelverkehr, Schulen, Kriminalität, Breitband, Lärm, Parks und lokalen Annehmlichkeiten.',
'Durchsuchen Sie jede Postleitzahl nach Budget, Immobilientyp, Wohnfläche, Eigentumsform, Pendelweg, Schulen, Kriminalität, Breitband, Lärm, Parks und örtlichen Annehmlichkeiten.',
'Search every postcode by budget, property type, size, tenure, commute, schools, crime, broadband, noise, parks, and local amenities instead of checking areas one at a time.':
'Durchsuchen Sie jede Postleitzahl nach Budget, Immobilientyp, Größe, Nutzungsdauer, Pendelverkehr, Schulen, Kriminalität, Breitband, Lärm, Parks und lokalen Annehmlichkeiten, anstatt die Gebiete einzeln zu überprüfen.',
'Durchsuchen Sie jede Postleitzahl nach Budget, Immobilientyp, Größe, Eigentumsform, Pendelweg, Schulen, Kriminalität, Breitband, Lärm, Parks und örtlichen Annehmlichkeiten, anstatt die Gebiete einzeln zu überprüfen.',
'Filter England-wide postcode data from one map.':
'Filtern Sie englandweite Postleitzahlendaten aus einer Karte.',
'Shortlist unfamiliar areas with comparable evidence.':
'Nehmen Sie unbekannte Gebiete mit vergleichbaren Beweisen in die engere Auswahl.',
'Nehmen Sie unbekannte Gebiete mit vergleichbaren Belegen in die engere Auswahl.',
'Save and share search areas before booking viewings.':
'Speichern und teilen Sie Suchbereiche, bevor Sie Besichtigungen buchen.',
'Turn a broad brief into postcode candidates':
'Verwandeln Sie einen umfassenden Auftrag in Postleitzahlenkandidaten',
'Enter the practical constraints first: budget, property size, tenure, travel time, school needs, broadband, and tolerance for road noise or crime levels. The map removes places that fail those constraints and keeps the remaining options comparable.':
'Geben Sie zunächst die praktischen Einschränkungen ein: Budget, Grundstücksgröße, Besitzdauer, Reisezeit, Schulbedarf, Breitbandanschluss und Toleranz gegenüber Straßenlärm oder Kriminalität. Die Karte entfernt Orte, die diese Einschränkungen nicht erfüllen, und sorgt dafür, dass die verbleibenden Optionen vergleichbar bleiben.',
'Geben Sie zunächst die praktischen Einschränkungen ein: Budget, Wohnfläche, Eigentumsform, Reisezeit, Schulbedarf, Breitbandanschluss und Toleranz gegenüber Straßenlärm oder Kriminalität. Die Karte entfernt Orte, die diese Einschränkungen nicht erfüllen, und sorgt dafür, dass die verbleibenden Optionen vergleichbar bleiben.',
'Relax one constraint at a time': 'Lockern Sie eine Einschränkung nach der anderen',
'When the search becomes too narrow, loosen a single filter and watch which postcodes reappear. This makes compromise explicit instead of relying on guesswork.':
'Wenn die Suche zu eng wird, lockern Sie einen einzelnen Filter und beobachten Sie, welche Postleitzahlen wieder auftauchen. Dies macht Kompromisse explizit, anstatt sich auf Vermutungen zu verlassen.',
@ -231,18 +234,18 @@ const de: Translations = {
'Search by destination first, then filter for property and neighbourhood fit.':
'Suchen Sie zuerst nach Reiseziel und filtern Sie dann nach der passenden Immobilie und Nachbarschaft.',
'Avoid areas that look close on a map but fail the daily journey.':
'Vermeiden Sie Gebiete, die auf einer Karte zwar nah anmutend aussehen, auf der täglichen Reise aber scheitern.',
'Vermeiden Sie Gebiete, die auf einer Karte nah aussehen, aber bei der täglichen Fahrt durchfallen.',
'Start with the destination that matters': 'Beginnen Sie mit dem Ziel, das zählt',
'Choose a commute destination, transport mode, and time range, then add the property filters. This prevents a cheap-looking area from reaching the shortlist if the daily journey doesnt work.':
'Wählen Sie ein Pendelziel, ein Transportmittel und einen Zeitraum aus und fügen Sie dann die Eigenschaftsfilter hinzu. Dadurch wird verhindert, dass ein günstig erscheinendes Gebiet in die engere Wahl kommt, wenn die tägliche Anreise nicht klappt.',
'Wählen Sie ein Pendelziel, ein Verkehrsmittel und einen Zeitrahmen aus und fügen Sie dann die Immobilienfilter hinzu. Dadurch wird verhindert, dass ein günstig wirkendes Gebiet in die engere Wahl kommt, wenn die tägliche Anreise nicht klappt.',
'Compare the commute against the rest of daily life':
'Vergleichen Sie den Weg zur Arbeit mit dem Rest des täglichen Lebens',
'A fast commute isnt enough if the property size, school context, safety threshold, broadband, or road-noise exposure dont fit. The map keeps those signals side by side.':
'Ein schnelles Pendeln reicht nicht aus, wenn die Grundstücksgröße, der Schulkontext, die Sicherheitsschwelle, das Breitbandnetz oder die Straßenlärmbelastung nicht passen. Die Karte hält diese Signale nebeneinander.',
'Ein schnelles Pendeln reicht nicht aus, wenn Wohnfläche, Schulkontext, Sicherheitsschwelle, Breitband oder Straßenlärm nicht passen. Die Karte zeigt diese Signale nebeneinander an.',
'Commute from postcodes, not just place names':
'Pendeln Sie über Postleitzahlen, nicht nur über Ortsnamen',
'Two streets in the same town can have very different station access, road routes, and public transport options. Postcode-level travel-time filtering keeps that difference visible.':
'Zwei Straßen in derselben Stadt können sehr unterschiedliche Bahnhofszufahrten, Straßenrouten und öffentliche Verkehrsmittel haben. Durch die Reisezeitfilterung auf Postleitzahlenebene bleibt dieser Unterschied sichtbar.',
'Zwei Straßen in derselben Stadt können sehr unterschiedliche Bahnanbindungen, Straßenrouten und ÖPNV-Optionen haben. Durch die Reisezeitfilterung auf Postleitzahlenebene bleibt dieser Unterschied sichtbar.',
'Balance journey time with the rest of the move':
'Gleichen Sie die Reisezeit mit dem Rest des Umzugs aus',
'A fast commute only helps if the area also fits your budget, housing needs, school preferences, safety threshold, broadband requirement, and tolerance for road noise.':
@ -250,37 +253,37 @@ const de: Translations = {
'How travel-time filters should be interpreted':
'Wie Reisezeitfilter interpretiert werden sollten',
'Travel-time modelling is useful for comparing areas consistently. Before committing, check current timetables, disruption patterns, parking, cycling conditions, and walking routes.':
'Die Reisezeitmodellierung ist nützlich, um Gebiete konsistent zu vergleichen. Bevor Sie sich verpflichten, prüfen Sie die aktuellen Fahrpläne, Störungsmuster, Parkmöglichkeiten, Fahrradbedingungen und Wanderrouten.',
'Die Reisezeitmodellierung ist nützlich, um Gebiete konsistent zu vergleichen. Bevor Sie sich festlegen, prüfen Sie aktuelle Fahrpläne, Störungsmuster, Parkmöglichkeiten, Radfahrbedingungen und Fußwege.',
'Why commute filters are combined with property data':
'Warum Pendelfilter mit Immobiliendaten kombiniert werden',
'Commute search is most useful when it removes impossible areas while still showing whether the remaining options are affordable and liveable.':
'Die Pendelsuche ist am nützlichsten, wenn sie unmögliche Bereiche entfernt und gleichzeitig anzeigt, ob die verbleibenden Optionen erschwinglich und lebenswert sind.',
'Can I compare car, cycling, walking, and public transport?':
'Kann ich Auto, Radfahren, Wandern und öffentliche Verkehrsmittel vergleichen?',
'Kann ich Auto, Fahrrad, Zu-Fuß-Gehen und öffentliche Verkehrsmittel vergleichen?',
'The product supports multiple travel modes where precomputed destination data is available.':
'Das Produkt unterstützt mehrere Reisemodi, bei denen vorberechnete Zieldaten verfügbar sind.',
'Are travel times exact?': 'Sind die Reisezeiten genau?',
'No. Treat them as a consistent comparison model, then verify the real route before making viewing or purchase decisions.':
'Nein. Behandeln Sie sie als konsistentes Vergleichsmodell und überprüfen Sie dann die tatsächliche Route, bevor Sie eine Betrachtungs- oder Kaufentscheidung treffen.',
'Nein. Behandeln Sie sie als konsistentes Vergleichsmodell und überprüfen Sie dann die tatsächliche Route, bevor Sie eine Besichtigungs- oder Kaufentscheidung treffen.',
'Can I combine commute filters with schools and price?':
'Kann ich Pendelfilter mit Schulen und Preis kombinieren?',
'Yes. The commute filter can be layered with property price, size, schools, broadband, crime, amenities, and environmental signals.':
'Ja. Der Pendelfilter kann mit Immobilienpreis, Größe, Schulen, Breitband, Kriminalität, Ausstattung und Umweltsignalen geschichtet werden.',
'Ja. Der Pendelfilter lässt sich mit Immobilienpreis, Größe, Schulen, Breitband, Kriminalität, Ausstattung und Umweltsignalen kombinieren.',
'Bristol property search guide': 'Leitfaden zur Immobiliensuche in Bristol',
'A worked example for balancing city access, price, and local context.':
'Ein praktisches Beispiel für den Ausgleich von Stadterreichbarkeit, Preis und lokalem Kontext.',
'Search by commute time': 'Suche nach Pendelzeit',
'Schools and property search': 'Suche nach Schulen und Immobilien',
'Find property search areas with schools and family trade-offs in view':
'Finden Sie Immobiliensuchgebiete mit Blick auf Schulen und Familienkonflikte',
'Finden Sie Immobiliensuchgebiete mit Blick auf Schulen und Familienkompromisse',
'School property search - Compare postcodes for family moves':
'Suche nach Schulgrundstücken Vergleichen Sie Postleitzahlen für Familienumzüge',
'Schulorientierte Immobiliensuche Vergleichen Sie Postleitzahlen für Familienumzüge',
'Compare nearby schools, property size, prices, parks, safety, commute and local amenities before building a viewing shortlist.':
'Vergleichen Sie Schulen in der Nähe, Grundstücksgröße, Preise, Parks, Sicherheit, Pendelverkehr und örtliche Annehmlichkeiten, bevor Sie eine Besichtigungsliste erstellen.',
'Vergleichen Sie Schulen in der Nähe, Wohnfläche, Preise, Parks, Sicherheit, Pendelweg und örtliche Annehmlichkeiten, bevor Sie eine Besichtigungs-Auswahlliste erstellen.',
'Compare nearby Ofsted ratings, education context, property size, budget, safety, parks, commute, and local amenities before narrowing your viewing shortlist.':
'Vergleichen Sie die Bewertungen von Ofsted in der Nähe, Bildungskontext, Grundstücksgröße, Budget, Sicherheit, Parks, Pendelverkehr und örtliche Annehmlichkeiten, bevor Sie Ihre Auswahlliste eingrenzen.',
'Vergleichen Sie Ofsted-Bewertungen in der Nähe, den Bildungskontext, die Wohnfläche, das Budget, die Sicherheit, Parks, den Pendelweg und örtliche Annehmlichkeiten, bevor Sie Ihre Besichtigungs-Auswahlliste eingrenzen.',
'Filter for nearby school quality alongside housing requirements.':
'Filtern Sie neben den Wohnbedürfnissen auch nach der Qualität einer Schule in der Nähe.',
'Filtern Sie nach der Qualität von Schulen in der Nähe zusammen mit den Wohnanforderungen.',
'Compare family-friendly trade-offs across unfamiliar postcodes.':
'Vergleichen Sie familienfreundliche Kompromisse über unbekannte Postleitzahlen hinweg.',
'Use the map as a shortlist tool before checking admissions and catchments.':
@ -288,7 +291,7 @@ const de: Translations = {
'Use school context without ignoring the home':
'Nutzen Sie den schulischen Kontext, ohne das Zuhause zu vernachlässigen',
'Start with property size, budget, and commute constraints, then layer in nearby school quality and local context. This prevents school-led searches from hiding affordability or daily-life problems.':
'Beginnen Sie mit der Grundstücksgröße, dem Budget und den Pendelbeschränkungen und berücksichtigen Sie dann die Qualität der nahegelegenen Schule und den lokalen Kontext. Dadurch wird verhindert, dass schulische Suchvorgänge Erschwinglichkeits- oder Alltagsprobleme verbergen.',
'Beginnen Sie mit Wohnfläche, Budget und Pendelbeschränkungen und ergänzen Sie dann die Qualität der nahegelegenen Schulen und den lokalen Kontext. Dadurch wird verhindert, dass schulgeführte Suchen Erschwinglichkeits- oder Alltagsprobleme verbergen.',
'Verify admissions before deciding':
'Überprüfen Sie die Zulassungen, bevor Sie eine Entscheidung treffen',
'School data can point to promising areas, but admissions rules and catchments can change. Confirm current arrangements with schools and local authorities.':
@ -296,7 +299,7 @@ const de: Translations = {
'School quality is one part of the shortlist':
'Die Schulqualität ist ein Teil der engeren Auswahl',
'Perfect Postcode helps you compare nearby school data with the other practical constraints that shape a family move: space, price, commute, parks, safety, and local services.':
'Mit Perfect Postcode können Sie die Daten einer nahegelegenen Schule mit den anderen praktischen Einschränkungen vergleichen, die einen Familienumzug beeinflussen: Platz, Preis, Pendelverkehr, Parks, Sicherheit und örtliche Dienstleistungen.',
'Mit Perfect Postcode können Sie die Daten naheliegender Schulen mit den anderen praktischen Einschränkungen vergleichen, die einen Familienumzug beeinflussen: Platz, Preis, Pendelweg, Parks, Sicherheit und örtliche Dienstleistungen.',
'Check catchments before making decisions':
'Überprüfen Sie die Einzugsgebiete, bevor Sie Entscheidungen treffen',
'Admissions rules and catchment boundaries can change. Use postcode-level school data to find promising areas, then verify current admissions details with the school or local authority.':
@ -306,29 +309,29 @@ const de: Translations = {
'Verwenden Sie Schulfilter, um die Recherche einzugrenzen und nicht, um eine Zulassungsberechtigung anzunehmen. Bewertungen, Entfernung, Zulassungskriterien und Schulkapazität sollten anhand aktueller offizieller Quellen überprüft werden.',
'Family trade-offs to compare': 'Familienkompromisse zum Vergleich',
'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.':
'Kombinieren Sie Schulen mit Parks, Straßenlärm, Kriminalität, Grundstücksgröße, Pendelverkehr, Breitband und Preis, damit die Auswahlliste den gesamten Umzug widerspiegelt.',
'Kombinieren Sie Schulen mit Parks, Straßenlärm, Kriminalität, Wohnfläche, Pendelweg, Breitband und Preis, damit die Auswahlliste den gesamten Umzug widerspiegelt.',
'Does this show school catchment guarantees?':
'Zeigt dies die Einzugsgebietsgarantien der Schule?',
'Zeigt dies garantierte Schul-Einzugsgebiete?',
'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.':
'Nein. Es hilft dabei, vielversprechende Gebiete zu identifizieren, Einzugsgebiete und Zulassungen müssen jedoch bei der Schule oder der örtlichen Behörde überprüft werden.',
'Can I combine school filters with parks and safety?':
'Kann ich Schulfilter mit Parks und Sicherheit kombinieren?',
'Yes. School-aware search can be combined with crime, parks, commute, price, property size, and local services.':
'Ja. Die schulbezogene Suche kann mit Kriminalität, Parks, Pendelverkehr, Preis, Grundstücksgröße und lokalen Dienstleistungen kombiniert werden.',
'Ja. Die schulbezogene Suche kann mit Kriminalität, Parks, Pendelweg, Preis, Wohnfläche und lokalen Dienstleistungen kombiniert werden.',
'Is Ofsted the only school signal?': 'Ist Ofsted das einzige Schulsignal?',
'No single score should decide a move. Use the map as a starting point, then review current school information in detail.':
'Kein einzelnes Ergebnis sollte über einen Zug entscheiden. Nutzen Sie die Karte als Ausgangspunkt und sehen Sie sich dann die aktuellen Schulinformationen im Detail an.',
'Kein einzelner Wert sollte über einen Umzug entscheiden. Nutzen Sie die Karte als Ausgangspunkt und sehen Sie sich dann die aktuellen Schulinformationen im Detail an.',
'See where education, property, transport, and environment data comes from.':
'Sehen Sie, woher Bildungs-, Immobilien-, Transport- und Umweltdaten stammen.',
'Explore school-aware searches': 'Entdecken Sie schulbezogene Suchanfragen',
'Check postcode data before you book a viewing':
'Überprüfen Sie die Postleitzahlendaten, bevor Sie eine Besichtigung buchen',
'Postcode checker - Property, crime, broadband, noise and schools':
'Postleitzahlenprüfer Eigentum, Kriminalität, Breitband, Lärm und Schulen',
'Postleitzahlenprüfer Immobilien, Kriminalität, Breitband, Lärm und Schulen',
'Check postcode-level property prices, EPC data, crime, broadband, road noise, schools, council tax, amenities and travel-time context.':
'Überprüfen Sie Immobilienpreise auf Postleitzahlenebene, EPC-Daten, Kriminalität, Breitband, Straßenlärm, Schulen, Gemeindesteuer, Annehmlichkeiten und Reisezeitkontext.',
'Überprüfen Sie Immobilienpreise auf Postleitzahlenebene, EPC-Daten, Kriminalität, Breitband, Straßenlärm, Schulen, Council Tax, Annehmlichkeiten und Reisezeitkontext.',
'Review property prices, EPC context, crime, broadband, road noise, local amenities, schools, deprivation, council tax, and travel-time data from one postcode-first map.':
'Überprüfen Sie Immobilienpreise, EPC-Kontext, Kriminalität, Breitband, Straßenlärm, örtliche Annehmlichkeiten, Schulen, Benachteiligung, Gemeindesteuer und Reisezeitdaten auf einer Postleitzahlenkarte.',
'Überprüfen Sie Immobilienpreise, EPC-Kontext, Kriminalität, Breitband, Straßenlärm, örtliche Annehmlichkeiten, Schulen, Deprivation, Council Tax und Reisezeitdaten auf einer postleitzahlenbasierten Karte.',
'Check multiple local signals before visiting a street.':
'Überprüfen Sie mehrere örtliche Signale, bevor Sie eine Straße besuchen.',
'Use official and open datasets rather than reputation alone.':
@ -336,25 +339,25 @@ const de: Translations = {
'Compare postcodes consistently across England.':
'Vergleichen Sie Postleitzahlen einheitlich in ganz England.',
'Check the street before spending a viewing slot':
'Überprüfen Sie die Straße, bevor Sie einen Besichtigungstermin verbringen',
'Prüfen Sie die Straße, bevor Sie einen Besichtigungstermin nutzen',
'Use the postcode checker to review price history, local context, amenities, schools, and environment signals before you commit time to visiting.':
'Nutzen Sie den Postleitzahlen-Checker, um die Preisentwicklung, den lokalen Kontext, die Ausstattung, Schulen und Umgebungssignale zu überprüfen, bevor Sie sich Zeit für einen Besuch nehmen.',
'Compare neighbouring postcodes': 'Vergleichen Sie benachbarte Postleitzahlen',
'If one postcode looks promising, compare adjacent areas using the same filters. This often reveals whether a concern is street-specific or part of a wider pattern.':
'Wenn eine Postleitzahl vielversprechend aussieht, vergleichen Sie benachbarte Gebiete mit denselben Filtern. Dies zeigt oft, ob ein Problem straßenspezifisch ist oder Teil eines umfassenderen Musters ist.',
'Wenn eine Postleitzahl vielversprechend aussieht, vergleichen Sie benachbarte Gebiete mit denselben Filtern. Dies zeigt oft, ob ein Problem straßenspezifisch ist oder Teil eines umfassenderen Musters.',
'Useful before and alongside listing portals': 'Nützlich vor und neben Immobilienportalen',
'Listing photos rarely tell you enough about the surrounding street. Perfect Postcode gives you an evidence-led postcode check before you commit time to a viewing.':
'Die Auflistungsfotos verraten Ihnen selten genug über die umliegende Straße. Perfect Postcode bietet Ihnen eine evidenzbasierte Postleitzahlenprüfung, bevor Sie sich Zeit für eine Besichtigung nehmen.',
'Inseratsfotos verraten Ihnen selten genug über die umliegende Straße. Perfect Postcode bietet Ihnen eine evidenzbasierte Postleitzahlenprüfung, bevor Sie sich Zeit für eine Besichtigung nehmen.',
'A screening tool, not professional advice':
'Ein Screening-Tool, keine professionelle Beratung',
'The data is designed for shortlisting and comparison. Any purchase still needs current listing checks, legal due diligence, flood searches, lender requirements, and survey findings.':
'Die Daten dienen der Auswahl und dem Vergleich. Für jeden Kauf sind weiterhin aktuelle Auflistungsprüfungen, rechtliche Due-Diligence-Prüfungen, Hochwasserrecherchen, Kreditgeberanforderungen und Umfrageergebnisse erforderlich.',
'What a postcode check can catch': 'Was ein Postleitzahlen-Check fangen kann',
'Die Daten dienen der Auswahl und dem Vergleich. Für jeden Kauf sind weiterhin aktuelle Inseratsprüfungen, rechtliche Due-Diligence-Prüfungen, Hochwasserrecherchen, Kreditgeberanforderungen und Gutachterergebnisse erforderlich.',
'What a postcode check can catch': 'Was eine Postleitzahlenprüfung erkennen kann',
'A postcode check can surface price context, environmental signals, nearby amenities, and other local indicators that are easy to miss in a listing.':
'Eine Postleitzahlenprüfung kann Preiskontext, Umweltsignale, nahegelegene Annehmlichkeiten und andere lokale Indikatoren aufdecken, die in einem Eintrag leicht übersehen werden.',
'Eine Postleitzahlenprüfung kann Preiskontext, Umweltsignale, nahegelegene Annehmlichkeiten und andere lokale Indikatoren aufdecken, die in einem Inserat leicht übersehen werden.',
'What a postcode check cant prove': 'Was eine Postleitzahlenprüfung nicht beweisen kann',
'It cant confirm the condition of a home, future development, legal title, lender requirements, or current street-level experience. Those still need direct checks.':
'Es kann nicht den Zustand eines Hauses, die zukünftige Entwicklung, den Rechtstitel, die Anforderungen des Kreditgebers oder die aktuelle Erfahrung auf Straßenniveau bestätigen. Diese benötigen noch direkte Kontrollen.',
'Sie kann nicht den Zustand eines Hauses, künftige Bauvorhaben, den Rechtstitel, die Anforderungen des Kreditgebers oder den aktuellen Eindruck auf Straßenebene bestätigen. Diese erfordern weiterhin direkte Prüfungen.',
'Can I use the checker before a viewing?':
'Kann ich den Checker vor einer Besichtigung nutzen?',
'Yes. Thats one of the main use cases: screen the postcode first, then decide whether the viewing is worth the time.':
@ -362,7 +365,7 @@ const de: Translations = {
'Does the checker include exact property condition?':
'Enthält der Prüfer den genauen Zustand der Immobilie?',
'No. Property condition requires listing details, surveys, and direct inspection.':
'Nein. Für den Immobilienzustand sind detaillierte Angaben zur Auflistung, Gutachten und eine direkte Besichtigung erforderlich.',
'Nein. Für den Immobilienzustand sind Inseratsangaben, Gutachten und eine direkte Besichtigung erforderlich.',
'Can I compare multiple postcodes?': 'Kann ich mehrere Postleitzahlen vergleichen?',
'Yes. The map is designed for consistent comparison across postcodes.':
'Ja. Die Karte ist für einen konsistenten Vergleich über mehrere Postleitzahlen hinweg konzipiert.',
@ -371,11 +374,11 @@ const de: Translations = {
'How to compare Birmingham postcodes before a property search':
'So vergleichen Sie die Postleitzahlen von Birmingham vor einer Immobiliensuche',
'Birmingham property search - Compare postcodes by price and commute':
'Immobiliensuche in Birmingham Vergleichen Sie Postleitzahlen nach Preis und Arbeitsweg',
'Immobiliensuche in Birmingham Vergleichen Sie Postleitzahlen nach Preis und Pendelweg',
'Use postcode-level data to compare Birmingham property prices, commute trade-offs, schools, crime, broadband and local amenities before viewings.':
'Nutzen Sie Daten auf Postleitzahlenebene, um Immobilienpreise in Birmingham, Kompromisse beim Pendeln, Schulen, Kriminalität, Breitband und örtliche Annehmlichkeiten vor Besichtigungen zu vergleichen.',
'Nutzen Sie Daten auf Postleitzahlenebene, um Immobilienpreise in Birmingham, Pendel-Kompromisse, Schulen, Kriminalität, Breitband und örtliche Annehmlichkeiten vor Besichtigungen zu vergleichen.',
'Birmingham searches can change quickly from street to street. Use postcode-level evidence to compare budget, commute, schools, noise, crime, and local services before deciding where to watch listings.':
'Die Suche in Birmingham kann sich von Straße zu Straße schnell ändern. Nutzen Sie Beweise auf Postleitzahlenebene, um Budget, Pendelverkehr, Schulen, Lärm, Kriminalität und örtliche Dienstleistungen zu vergleichen, bevor Sie entscheiden, wo Sie Einträge ansehen möchten.',
'Die Suche in Birmingham kann sich von Straße zu Straße schnell ändern. Nutzen Sie Belege auf Postleitzahlenebene, um Budget, Pendelweg, Schulen, Lärm, Kriminalität und örtliche Dienstleistungen zu vergleichen, bevor Sie entscheiden, wo Sie Inserate beobachten möchten.',
'Start with commute corridors': 'Beginnen Sie mit Pendelkorridoren',
'Choose the destination that matters, such as a workplace, station, university, or hospital, then compare reachable postcodes by transport mode and travel-time band.':
'Wählen Sie das gewünschte Ziel aus, z. B. einen Arbeitsplatz, einen Bahnhof, eine Universität oder ein Krankenhaus, und vergleichen Sie dann erreichbare Postleitzahlen nach Verkehrsmittel und Reisezeitspanne.',
@ -385,21 +388,21 @@ const de: Translations = {
'Vergleichen Sie öffentliche Verkehrsmittel mit dem Auto, dem Fahrrad oder zu Fuß, sofern verfügbar.',
'Check the route manually before booking viewings.':
'Überprüfen Sie die Route manuell, bevor Sie Besichtigungen buchen.',
'Compare price with property type': 'Vergleichen Sie den Preis mit der Immobilienart',
'Compare price with property type': 'Vergleichen Sie den Preis mit dem Immobilientyp',
'Median prices alone can be misleading if the local property mix changes. Add property type, tenure, floor area, and price filters so similar areas are compared fairly.':
'Allein die Medianpreise können irreführend sein, wenn sich der Immobilienmix vor Ort ändert. Fügen Sie Filter für Immobilienart, Grundstücksfläche, Grundfläche und Preis hinzu, damit ähnliche Flächen fair verglichen werden können.',
'Medianpreise allein können irreführend sein, wenn sich der lokale Immobilienmix ändert. Fügen Sie Filter für Immobilientyp, Eigentumsform, Wohnfläche und Preis hinzu, damit ähnliche Gebiete fair verglichen werden.',
'Keep family and environment trade-offs visible':
'Halten Sie die Kompromisse zwischen Familie und Umwelt sichtbar',
'Layer school context, parks, road noise, broadband, and crime signals on top of the property filters. That makes it easier to decide which compromises are acceptable.':
'Überlagern Sie den Grundstücksfilter mit Schulkontext, Parks, Straßenlärm, Breitband und Kriminalitätssignalen. Das erleichtert die Entscheidung, welche Kompromisse akzeptabel sind.',
'Überlagern Sie die Immobilienfilter mit Schulkontext, Parks, Straßenlärm, Breitband und Kriminalitätssignalen. Das erleichtert die Entscheidung, welche Kompromisse akzeptabel sind.',
'Can Perfect Postcode tell me the best area in Birmingham?':
'Kann mir Perfect Postcode die beste Gegend in Birmingham nennen?',
'No tool can decide the best area for every buyer. It helps compare postcodes against your own constraints so you can build a better shortlist.':
'Kein Tool kann für jeden Käufer den besten Bereich bestimmen. Es hilft Ihnen, Postleitzahlen mit Ihren eigenen Einschränkungen zu vergleichen, sodass Sie eine bessere Auswahlliste erstellen können.',
'Kein Tool kann für jeden Käufer das beste Gebiet bestimmen. Es hilft Ihnen, Postleitzahlen mit Ihren eigenen Anforderungen abzugleichen, sodass Sie eine bessere Auswahlliste erstellen können.',
'Should I use this instead of local knowledge?':
'Sollte ich dies anstelle von lokalem Wissen verwenden?',
'No. Use it to find and compare candidates, then validate them with visits, local advice, listings, and official checks.':
'Nein. Verwenden Sie es, um Kandidaten zu finden und zu vergleichen und sie dann durch Besuche, Beratung vor Ort, Auflistungen und offizielle Überprüfungen zu validieren.',
'Nein. Verwenden Sie es, um Kandidaten zu finden und zu vergleichen, und validieren Sie sie dann durch Besuche, Beratung vor Ort, Inserate und offizielle Überprüfungen.',
'Compare price patterns before looking at live listings.':
'Vergleichen Sie Preismuster, bevor Sie sich Live-Angebote ansehen.',
'Search by travel time and then layer on property requirements.':
@ -412,9 +415,9 @@ const de: Translations = {
'Manchester property search - Compare postcodes before viewing':
'Immobiliensuche in Manchester Vergleichen Sie die Postleitzahlen vor der Besichtigung',
'Compare Manchester-area postcodes by budget, commute, property type, schools, broadband, crime, noise and amenities before booking viewings.':
'Vergleichen Sie Postleitzahlen im Raum Manchester nach Budget, Pendlerweg, Immobilientyp, Schulen, Breitband, Kriminalität, Lärm und Ausstattung, bevor Sie Besichtigungen buchen.',
'Vergleichen Sie Postleitzahlen im Raum Manchester nach Budget, Pendelweg, Immobilientyp, Schulen, Breitband, Kriminalität, Lärm und Ausstattung, bevor Sie Besichtigungen buchen.',
'A Manchester-area search can span city-centre, suburban, and commuter options. Perfect Postcode helps keep each postcode comparable against the same property and daily-life constraints.':
'Eine Suche im Raum Manchester kann Optionen im Stadtzentrum, in Vorstädten und für Pendler umfassen. Perfect Postcode trägt dazu bei, dass jede Postleitzahl mit den gleichen Grundstücks- und Alltagsbeschränkungen vergleichbar bleibt.',
'Eine Suche im Raum Manchester kann Optionen im Stadtzentrum, in Vorstädten und für Pendler umfassen. Perfect Postcode trägt dazu bei, dass jede Postleitzahl unter den gleichen Immobilien- und Alltagsanforderungen vergleichbar bleibt.',
'Use travel time to define the real search area':
'Nutzen Sie die Reisezeit, um das tatsächliche Suchgebiet zu definieren',
'Start from the destinations that matter, then compare reachable postcodes rather than assuming every nearby place has the same practical journey.':
@ -422,22 +425,22 @@ const de: Translations = {
'Compare housing requirements before lifestyle preferences':
'Vergleichen Sie zunächst die Wohnanforderungen und dann Ihre Lebensstilpräferenzen',
'Filter by property type, floor area, tenure, and price before judging amenities. That keeps the shortlist grounded in homes that could realistically work.':
'Filtern Sie nach Immobilientyp, Grundfläche, Nutzungsdauer und Preis, bevor Sie die Ausstattung beurteilen. Dadurch bleibt die Auswahlliste auf Häusern beschränkt, die realistisch funktionieren könnten.',
'Filtern Sie nach Immobilientyp, Wohnfläche, Eigentumsform und Preis, bevor Sie die Ausstattung beurteilen. Dadurch bleibt die Auswahlliste auf Häuser beschränkt, die realistisch in Frage kommen.',
'Check local context consistently': 'Überprüfen Sie den lokalen Kontext konsequent',
'Use broadband, crime, road noise, parks, schools, and amenities as comparable signals. Then validate the strongest candidates with current local checks.':
'Nutzen Sie Breitband, Kriminalität, Straßenlärm, Parks, Schulen und Einrichtungen als vergleichbare Signale. Anschließend validieren Sie die stärksten Kandidaten mit aktuellen lokalen Prüfungen.',
'Can I compare Manchester suburbs with city-centre postcodes?':
'Kann ich Vororte von Manchester mit Postleitzahlen im Stadtzentrum vergleichen?',
'Yes. Use the same budget, property, commute, and local-context filters across both so trade-offs remain visible.':
'Ja. Verwenden Sie für beide die gleichen Budget-, Immobilien-, Pendler- und lokalen Kontextfilter, damit Kompromisse sichtbar bleiben.',
'Does this include live listings?': 'Umfasst dies Live-Einträge?',
'Ja. Verwenden Sie für beide die gleichen Filter für Budget, Immobilie, Pendelweg und lokalen Kontext, damit Kompromisse sichtbar bleiben.',
'Does this include live listings?': 'Umfasst dies aktuelle Inserate?',
'No. Use it to decide where to search, then use listing portals for current homes for sale.':
'Nein. Verwenden Sie es, um zu entscheiden, wo Sie suchen möchten, und nutzen Sie dann die Angebotsportale für aktuelle zum Verkauf stehende Häuser.',
'Nein. Verwenden Sie es, um zu entscheiden, wo Sie suchen möchten, und nutzen Sie dann die Immobilienportale für aktuell zum Verkauf stehende Häuser.',
'Move from a broad search brief to specific postcode candidates.':
'Wechseln Sie von einem breiten Suchauftrag zu bestimmten Postleitzahlkandidaten.',
'Wechseln Sie von einem breiten Suchprofil zu konkreten Postleitzahl-Kandidaten.',
'Data sources': 'Datenquellen',
'Review the datasets used for property and local-context comparison.':
'Überprüfen Sie die Datensätze, die für den Eigenschafts- und lokalen Kontextvergleich verwendet werden.',
'Überprüfen Sie die Datensätze, die für den Vergleich von Immobilien und lokalem Kontext verwendet werden.',
'Check a single postcode before arranging a viewing.':
'Überprüfen Sie eine einzelne Postleitzahl, bevor Sie eine Besichtigung vereinbaren.',
'Compare Manchester postcodes': 'Vergleichen Sie die Postleitzahlen von Manchester',
@ -446,16 +449,16 @@ const de: Translations = {
'Bristol property search - Compare postcodes by commute and price':
'Immobiliensuche in Bristol Vergleichen Sie Postleitzahlen nach Pendelweg und Preis',
'Compare Bristol postcodes by price, commute, property size, schools, broadband, crime, road noise, parks and amenities before viewings.':
'Vergleichen Sie die Postleitzahlen von Bristol nach Preis, Pendlerweg, Grundstücksgröße, Schulen, Breitband, Kriminalität, Straßenlärm, Parks und Annehmlichkeiten vor der Besichtigung.',
'Vergleichen Sie die Postleitzahlen von Bristol nach Preis, Pendelweg, Wohnfläche, Schulen, Breitband, Kriminalität, Straßenlärm, Parks und Annehmlichkeiten vor der Besichtigung.',
'Bristol searches often involve sharp trade-offs between price, journey time, property size, and neighbourhood context. A postcode-first comparison keeps those trade-offs visible.':
'Bei Suchanfragen in Bristol müssen oft scharfe Kompromisse zwischen Preis, Reisezeit, Grundstücksgröße und Nachbarschaftskontext eingegangen werden. Ein Postleitzahlenvergleich macht diese Kompromisse sichtbar.',
'Bei Suchanfragen in Bristol müssen oft deutliche Kompromisse zwischen Preis, Reisezeit, Wohnfläche und Nachbarschaftskontext eingegangen werden. Ein postleitzahlenbasierter Vergleich macht diese Kompromisse sichtbar.',
'Make commute constraints explicit': 'Machen Sie Pendelbeschränkungen explizit',
'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.':
'Wenn die Erreichbarkeit des Zentrums, eines Bahnhofs, eines Krankenhauses, einer Universität oder eines Gewerbegebiets von Bedeutung ist, verwenden Sie zunächst Reisezeitfilter und vergleichen Sie dann die übrigen Postleitzahlen anhand der Grundstücksdaten.',
'Wenn die Erreichbarkeit des Zentrums, eines Bahnhofs, eines Krankenhauses, einer Universität oder eines Gewerbegebiets von Bedeutung ist, verwenden Sie zunächst Reisezeitfilter und vergleichen Sie dann die übrigen Postleitzahlen anhand der Immobiliendaten.',
'Compare value, not just headline price':
'Vergleichen Sie den Wert, nicht nur den Gesamtpreis',
'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.':
'Nutzen Sie Preis-, Immobilientyp- und Flächenfilter gemeinsam. Dies hilft dabei, kostengünstigere Gebiete von Gebieten zu unterscheiden, in denen sich lediglich kleinere oder andere Häuser befinden.',
'Nutzen Sie Preis-, Immobilientyp- und Wohnflächenfilter gemeinsam. Dies hilft dabei, günstigere Gebiete von Gebieten zu unterscheiden, in denen sich lediglich kleinere oder andere Häuser befinden.',
'Screen environmental and local-service signals':
'Überprüfen Sie Umgebungs- und lokale Servicesignale',
'Road noise, parks, broadband, crime, and amenities can affect whether a property works day to day. Use them as screening criteria before booking viewings.':
@ -465,43 +468,43 @@ const de: Translations = {
'Yes, where the relevant postcode and travel-time data is available. Always verify routes and services manually before deciding.':
'Ja, sofern die entsprechenden Postleitzahlen- und Reisezeitdaten verfügbar sind. Überprüfen Sie Routen und Dienste immer manuell, bevor Sie eine Entscheidung treffen.',
'Can this tell me whether a listing is good value?':
'Kann mir das sagen, ob ein Eintrag ein gutes Preis-Leistungs-Verhältnis bietet?',
'Kann mir das sagen, ob ein Inserat ein gutes Preis-Leistungs-Verhältnis bietet?',
'It can provide area context, but a specific listing still needs comparable sales, condition checks, survey findings, and professional advice where appropriate.':
'Es kann einen gebietsbezogenen Kontext bieten, aber für ein bestimmtes Angebot sind dennoch vergleichbare Verkäufe, Zustandsprüfungen, Umfrageergebnisse und gegebenenfalls professionelle Beratung erforderlich.',
'Es kann einen gebietsbezogenen Kontext bieten, aber für ein bestimmtes Inserat sind dennoch vergleichbare Verkäufe, Zustandsprüfungen, Gutachterergebnisse und gegebenenfalls professionelle Beratung erforderlich.',
'Search by reachable postcodes before refining by budget and local context.':
'Suchen Sie nach erreichbaren Postleitzahlen, bevor Sie die Suche nach Budget und lokalem Kontext verfeinern.',
'Understand price patterns before setting listing alerts.':
'Verstehen Sie Preismuster, bevor Sie Angebotsbenachrichtigungen einrichten.',
'Verstehen Sie Preismuster, bevor Sie Inseratsbenachrichtigungen einrichten.',
'Privacy and security': 'Privatsphäre und Sicherheit',
'How account and saved-search data is handled in the product.':
'Wie Konto- und gespeicherte Suchdaten im Produkt verarbeitet werden.',
'Compare Bristol postcodes': 'Vergleichen Sie die Postleitzahlen von Bristol',
'Trust and coverage': 'Vertrauen und Abdeckung',
'Perfect Postcode data sources and coverage':
'Perfekte Postleitzahlen-Datenquellen und -Abdeckung',
'Perfect Postcode Datenquellen und Abdeckung',
'Perfect Postcode data sources - Property, schools, commute and local context':
'Perfekte Postleitzahlen-Datenquellen Immobilien, Schulen, Pendler und lokaler Kontext',
'Perfect Postcode Datenquellen: Immobilien, Schulen, Pendelweg und lokaler Kontext',
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.':
'Überprüfen Sie die von Perfect Postcode verwendeten öffentlichen und offiziellen Datensätze, einschließlich Immobilienpreise, EPC, Schulen, Kriminalität, Breitband, Lärm und Reisezeitkontext.',
'Perfect Postcode combines property, transport, education, environment, and local-service datasets so buyers can compare postcodes consistently. This page explains what the data is for and where it should be verified.':
'Perfect Postcode kombiniert Immobilien-, Transport-, Bildungs-, Umwelt- und lokale Dienstleistungsdatensätze, sodass Käufer Postleitzahlen konsistent vergleichen können. Auf dieser Seite wird erläutert, wozu die Daten dienen und wo sie überprüft werden sollten.',
'Property and housing context': 'Immobilien- und Wohnkontext',
'The product uses property transaction and housing-context datasets to support filters such as sale price, property type, tenure, floor area, energy performance, and estimated current value.':
'Das Produkt nutzt Immobilientransaktions- und Wohnungskontextdatensätze, um Filter wie Verkaufspreis, Immobilientyp, Besitz, Grundfläche, Energieeffizienz und geschätzten aktuellen Wert zu unterstützen.',
'Das Produkt nutzt Datensätze zu Immobilientransaktionen und zum Wohnkontext, um Filter wie Verkaufspreis, Immobilientyp, Eigentumsform, Wohnfläche, Energieeffizienz und geschätzten aktuellen Wert zu unterstützen.',
'Use these fields to compare areas, not as a formal valuation.':
'Verwenden Sie diese Felder zum Vergleichen von Bereichen, nicht als formale Bewertung.',
'Verwenden Sie diese Felder zum Vergleichen von Gebieten, nicht als formale Wertermittlung.',
'Check current listings, title information, lender requirements, and survey results before buying.':
'Überprüfen Sie vor dem Kauf aktuelle Angebote, Titelinformationen, Kreditgeberanforderungen und Umfrageergebnisse.',
'Überprüfen Sie vor dem Kauf aktuelle Inserate, Grundbuchinformationen, Kreditgeberanforderungen und Gutachterergebnisse.',
'Schools, safety, broadband, and environment': 'Schulen, Sicherheit, Breitband und Umwelt',
'Local-context filters help compare postcodes on signals that affect daily life. They should be treated as screening data and checked against current official sources for decisions.':
'Lokale Kontextfilter helfen beim Vergleich von Postleitzahlen anhand von Signalen, die das tägliche Leben beeinflussen. Sie sollten als Screening-Daten behandelt und für Entscheidungen mit aktuellen offiziellen Quellen verglichen werden.',
'Travel-time data': 'Reisezeitdaten',
'Travel-time filters are designed for consistent area comparison. Route availability, disruption, parking, walking access, and timetable details should be verified before committing to an area.':
'Reisezeitfilter sind für einen konsistenten Gebietsvergleich konzipiert. Bevor Sie sich für ein Gebiet entscheiden, sollten Sie die Verfügbarkeit der Route, Störungen, Parkmöglichkeiten, Zugang zu Fuß und Fahrplandetails überprüfen.',
'Reisezeitfilter sind für einen konsistenten Gebietsvergleich konzipiert. Bevor Sie sich für ein Gebiet entscheiden, sollten Sie Routenverfügbarkeit, Störungen, Parkmöglichkeiten, Fußläufigkeit und Fahrplandetails überprüfen.',
'Why does coverage focus on England?':
'Warum konzentriert sich die Berichterstattung auf England?',
'Warum konzentriert sich die Abdeckung auf England?',
'Several core property, education, and local-context datasets are jurisdiction-specific. England coverage keeps comparisons more consistent.':
'Mehrere Kerndatensätze zu Eigentum, Bildung und lokalem Kontext sind gebietsspezifisch. Die Berichterstattung über England sorgt für konsistentere Vergleiche.',
'Mehrere zentrale Datensätze zu Immobilien, Bildung und lokalem Kontext sind jurisdiktionsspezifisch. Eine Abdeckung von England sorgt für konsistentere Vergleiche.',
'How should I handle stale or missing data?':
'Wie gehe ich mit veralteten oder fehlenden Daten um?',
'Use the map as a shortlist tool. If a postcode matters, verify the latest details with current official sources and direct local checks.':
@ -516,52 +519,52 @@ const de: Translations = {
'Methodology for postcode property research':
'Methodik für die Immobilienrecherche nach Postleitzahlen',
'Perfect Postcode methodology - How to interpret postcode property data':
'Perfekte Postleitzahlen-Methodik So interpretieren Sie Postleitzahlen-Eigenschaftsdaten',
'Perfect Postcode Methodik So interpretieren Sie Immobiliendaten auf Postleitzahlenebene',
'Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.':
'Verstehen Sie, wie Sie Postleitzahlenfilter, Immobilienschätzungen, Reisezeitdaten, Schulkontext und lokale Signale als Tool für die Auswahlliste beim Hauskauf verwenden.',
'Perfect Postcode is designed to make area shortlisting more evidence-led. It doesnt replace estate agents, surveyors, conveyancers, lenders, school admissions teams, or local authority checks.':
'Perfect Postcode wurde entwickelt, um die Auswahl von Gebieten evidenzbasierter zu gestalten. Es ersetzt nicht Immobilienmakler, Gutachter, Immobilienmakler, Kreditgeber, Schulzulassungsteams oder örtliche Behördenkontrollen.',
'Perfect Postcode wurde entwickelt, um die Gebietsauswahl evidenzbasierter zu gestalten. Es ersetzt keine Immobilienmakler, Gutachter, Notare, Kreditgeber, Schulzulassungsteams oder Prüfungen durch örtliche Behörden.',
'Start with hard constraints': 'Beginnen Sie mit harten Einschränkungen',
'Begin with non-negotiables such as budget, property type, floor area, commute time, and essential services. This removes impossible postcodes before softer preferences are considered.':
'Beginnen Sie mit nicht verhandelbaren Faktoren wie Budget, Immobilientyp, Grundfläche, Pendelzeit und wesentlichen Dienstleistungen. Dadurch werden unmögliche Postleitzahlen entfernt, bevor weichere Präferenzen berücksichtigt werden.',
'Beginnen Sie mit nicht verhandelbaren Faktoren wie Budget, Immobilientyp, Wohnfläche, Pendelzeit und wesentlichen Dienstleistungen. Dadurch werden unmögliche Postleitzahlen ausgeschlossen, bevor weichere Präferenzen berücksichtigt werden.',
'Use colour layers for trade-offs': 'Verwenden Sie Farbebenen für Kompromisse',
'After filtering, colour the remaining map by one signal at a time: price per square metre, road noise, school context, commute time, broadband, or crime. This makes trade-offs easier to discuss.':
'Färben Sie die verbleibende Karte nach dem Filtern jeweils nach einem Signal ein: Preis pro Quadratmeter, Straßenlärm, Schulkontext, Pendelzeit, Breitband oder Kriminalität. Dies erleichtert die Diskussion von Kompromissen.',
'Measure whats working': 'Messen Sie, was funktioniert',
'Use Search Console and analytics to track which public pages are indexed, which queries produce impressions, and which pages convert visitors into dashboard exploration. Review Core Web Vitals after every substantial frontend change.':
'Verwenden Sie die Search Console und Analysen, um zu verfolgen, welche öffentlichen Seiten indiziert werden, welche Abfragen Impressionen erzeugen und welche Seiten Besucher in Dashboard-Erkundungen umwandeln. Überprüfen Sie die Core Web Vitals nach jeder wesentlichen Frontend-Änderung.',
'Verwenden Sie die Search Console und Analysen, um zu verfolgen, welche öffentlichen Seiten indexiert werden, welche Abfragen Impressionen erzeugen und welche Seiten Besucher zur Dashboard-Nutzung führen. Überprüfen Sie die Core Web Vitals nach jeder wesentlichen Frontend-Änderung.',
'Can the tool choose the right postcode for me?':
'Kann das Tool die richtige Postleitzahl für mich auswählen?',
'No. It helps compare evidence and reduce the search area. The final decision needs direct visits, current listings, legal checks, surveys, and personal judgement.':
'Nein. Es hilft, Beweise zu vergleichen und den Suchbereich zu verkleinern. Die endgültige Entscheidung erfordert direkte Besuche, aktuelle Einträge, rechtliche Prüfungen, Umfragen und ein persönliches Urteil.',
'Nein. Es hilft, Belege zu vergleichen und das Suchgebiet einzugrenzen. Die endgültige Entscheidung erfordert direkte Besuche, aktuelle Inserate, rechtliche Prüfungen, Gutachten und persönliches Urteilsvermögen.',
'How should I use estimates?': 'Wie verwende ich Schätzungen?',
'Use estimates as comparison signals, not as professional valuations or purchase advice.':
'Nutzen Sie Schätzungen als Vergleichssignale, nicht als professionelle Wertermittlung oder Kaufberatung.',
'Understand where key filters come from.': 'Verstehen Sie, woher Schlüsselfilter kommen.',
'Apply the methodology to price-led area comparison.':
'Wenden Sie die Methodik auf einen preisorientierten Flächenvergleich an.',
'Wenden Sie die Methodik auf einen preisgeführten Gebietsvergleich an.',
'Apply the methodology to destination-led search.':
'Wenden Sie die Methodik auf die zielorientierte Suche an.',
'Wenden Sie die Methodik auf die zielgeführte Suche an.',
Trust: 'Vertrauen',
'Privacy and security for saved property searches':
'Datenschutz und Sicherheit für gespeicherte Immobiliensuchen',
'Perfect Postcode privacy and security - Saved searches and account data':
'Perfekte Privatsphäre und Sicherheit für Postleitzahlen Gespeicherte Suchanfragen und Kontodaten',
'Perfect Postcode Datenschutz und Sicherheit Gespeicherte Suchanfragen und Kontodaten',
'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.':
'Erfahren Sie, wie Perfect Postcode gespeicherte Suchanfragen, Kontodaten und Immobilienrecherche-Workflows unter Berücksichtigung von Datenschutz und Sicherheit behandelt.',
'Property research can reveal personal priorities, budgets, and locations. The product keeps public SEO pages separate from account-only areas and marks private dashboard/account routes as noindex.':
'Eine Immobilienrecherche kann persönliche Prioritäten, Budgets und Standorte aufdecken. Das Produkt trennt öffentliche SEO-Seiten von reinen Kontobereichen und markiert private Dashboard-/Kontorouten als Noindex.',
'Eine Immobilienrecherche kann persönliche Prioritäten, Budgets und Standorte offenlegen. Das Produkt trennt öffentliche SEO-Seiten von kontogebundenen Bereichen und markiert private Dashboard-/Kontorouten als noindex.',
'Public pages and private areas are separated':
'Öffentliche Seiten und private Bereiche werden getrennt',
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
'Marketing-, Methodik-, Leitfaden- und Supportseiten sind indexierbar. Dashboard, Konto, gespeicherte Suchen, Einladungen und Einladungsrouten werden gegebenenfalls als „noindex“ markiert oder für den Crawler-Zugriff blockiert.',
'Marketing-, Methodik-, Leitfaden- und Supportseiten sind indexierbar. Dashboard, Konto, gespeicherte Suchen und Einladungsrouten werden gegebenenfalls als noindex markiert oder für den Crawler-Zugriff gesperrt.',
'Saved search data is account-scoped': 'Gespeicherte Suchdaten sind kontobezogen',
'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'Gespeicherte Suchen und geteilte Links sind für angemeldete Benutzer vorgesehen. Sie sind nicht in der öffentlichen Sitemap enthalten und sollten nicht als öffentlicher Inhalt gecrawlt werden können.',
'Search measurement without exposing private data':
'Suchmessung ohne Offenlegung privater Daten',
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldnt become indexable landing pages.':
'Die SEO-Messung sollte auf öffentlichen Seiten mithilfe aggregierter Analysen und Search Console-Daten erfolgen. Private Abfrageparameter und Kontoansichten sollten nicht zu indexierbaren Zielseiten werden.',
'SEO-Messung sollte auf öffentlichen Seiten mit aggregierten Analysen und Search-Console-Daten erfolgen. Private Abfrageparameter und Kontoansichten sollten nicht zu indexierbaren Landingpages werden.',
'Are saved searches listed in the sitemap?':
'Werden gespeicherte Suchanfragen in der Sitemap aufgeführt?',
'No. Public SEO pages are listed; account and saved-search routes are intentionally excluded.':
@ -569,11 +572,11 @@ const de: Translations = {
'Can private dashboard URLs appear in search?':
'Können private Dashboard-URLs in der Suche angezeigt werden?',
'They shouldnt be indexed. The server marks private routes noindex and the sitemap only lists public pages.':
'Sie sollten nicht indiziert werden. Der Server markiert private Routen mit Noindex und die Sitemap listet nur öffentliche Seiten auf.',
'Sie sollten nicht indexiert werden. Der Server markiert private Routen als noindex und die Sitemap listet nur öffentliche Seiten auf.',
'How to use public postcode data responsibly.':
'So gehen Sie verantwortungsvoll mit öffentlichen Postleitzahldaten um.',
'What data powers the public comparisons.':
'Welche Daten basieren auf den öffentlichen Vergleichen?',
'Welche Daten den öffentlichen Vergleichen zugrunde liegen.',
'Explore public postcode-search workflows.':
'Entdecken Sie öffentliche Workflows zur Postleitzahlensuche.',
},
@ -677,8 +680,12 @@ const de: Translations = {
clearAll: 'Alle löschen',
clearAllTitle: 'Alle Filter löschen?',
clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
clearAllUpdatePrompt:
'<strong>{{name}}</strong> mit den aktuellen Filtern aktualisieren, bevor gelöscht wird?',
saveAndClear: 'Speichern & löschen',
updateAndClear: 'Aktualisieren & löschen',
clearWithoutSaving: 'Ohne Speichern löschen',
clearWithoutUpdating: 'Ohne Aktualisieren löschen',
filtersOut: 'filtert {{value}} heraus',
schoolType: 'Schultyp',
schoolRating: 'Schulbewertung',
@ -829,10 +836,13 @@ const de: Translations = {
showAllStatsFallback:
'Wechseln Sie zu allen Immobilien, um dieses Gebiet ohne aktive Filter zu prüfen.',
showAllStats: 'Alle Immobilien anzeigen',
closestBlockingFilters: 'Nächste Filter, die dieses Gebiet ausschließen',
closestBlockingFilters: 'Nächste Änderungen, um dieses Gebiet einzuschließen',
lowerMinTo: 'Minimum auf {{value}} senken',
raiseMaxTo: 'Maximum auf {{value}} erhöhen',
allowCategory: '{{value}} zulassen',
missingFilterValue:
'Kein Wert für diesen Filter; entfernen Sie ihn oder lassen Sie fehlende Werte zu',
noFilterDataShort: 'Keine Daten',
travelTo: 'Fahrt zu {{destination}}',
viewProperties: '{{count}} Immobilien ansehen',
viewPropertiesShort: 'Immobilien ansehen',
@ -898,18 +908,18 @@ const de: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroEyebrow: 'Finden Sie zuerst, wo Sie suchen sollten',
heroTitle1: 'Suchen Sie nicht länger',
heroTitle2: 'an den falschen Orten',
heroTitle3: 'Bevor Inserate Ihre Suche verengen.',
heroEyebrow: 'Für Käufer, die fragen: „Wo soll ich überhaupt suchen?“',
heroTitle1: 'Finden Sie die Postleitzahlen',
heroTitle2: 'die zu Ihrem Leben passen',
heroTitle3: 'Nicht nur die Gegenden, die Sie schon kennen.',
heroSubtitle:
'Finden Sie Postleitzahlen, bei denen Budget, Pendelweg und Alltag zusammenpassen.',
'Von Londoner Stadtteilen über Pendlerorte bis zu regionalen Städten: England hat zu viele Orte, um sie einzeln zu recherchieren.',
heroDescription:
'Perfect Postcode filtert zuerst jede Postleitzahl, damit Sie Besichtigungen nur dort verfolgen, wo es wirklich passt.',
exploreTheMap: 'Zeigen Sie mir, wo ich suchen soll',
seeTheDifference: 'Demo ansehen',
productDemoLabel: 'Sehen, wie Sie zuerst den richtigen Suchort finden',
playProductDemo: 'Suchort-Demo abspielen',
'Legen Sie Budget, Pendelzeit, Schulen, Sicherheit, Lärm, Breitband und Lebensstil fest. Perfect Postcode scannt Englands Postleitzahlen und zeigt Orte, die wirklich passen, auch Gegenden, die Sie nie in ein Immobilienportal eingegeben hätten.',
exploreTheMap: 'Passende Postleitzahlen finden',
seeTheDifference: 'So funktioniert es',
productDemoLabel: 'Perfect Postcode-Produktdemo',
playProductDemo: 'Perfect Postcode-Produktdemo abspielen',
scrollToProductDemo: 'Zur Produktdemo scrollen',
showcaseHeader: 'So funktioniert es',
showcaseContext: 'So funktioniert Perfect Postcode',
@ -917,44 +927,45 @@ const de: Translations = {
showcaseFeatureNoiseShort: 'Lärm',
showcaseFeatureSchoolsShort: 'Schulen',
showcaseFeatureTravelShort: 'Fahrzeit',
showcaseGoodPrimariesNearby: '{{count}}+ gute oder hervorragende Grundschulen in der Nähe',
showcaseWithinRail: 'Innerhalb von {{count}} Min. eines Bahnhofs',
showcaseMatchingHomesLabel: 'Passende Postleitzahlen',
showcaseMatchingHomes: '{{value}} passende Postleitzahlen',
showcaseGoodPrimariesNearby: '{{count}}+ gute Grundschulen in der Nähe',
showcaseWithinRail: 'Innerhalb von {{count}} Min. zur Bahn',
showcaseMatchingHomesLabel: 'Passende Immobilien',
showcaseMatchingHomes: '{{value}} passende Immobilien',
showcaseMedianPrice: '{{value}} Median',
showcaseJourneyRoutes: 'Routen',
showcaseNearby: '{{value}} in der Nähe',
showcasePoliticalVoteShare: 'Politischer Stimmenanteil',
showcaseLotsMore: 'Mehr Nachbarschaftsdaten',
showcaseLotsMore: '...und vieles mehr',
showcaseMinutes: '{{count}} Min.',
showcaseSendShortlist: 'Auswahlliste senden',
showcaseDownloadXlsx: '.xlsx herunterladen',
showcaseTopThree: 'Top 3',
showcaseScoutBullet1: 'Prüfen Sie die Straße, bevor Sie sich auf Inseratsalarme festlegen.',
showcaseScoutBullet1:
'Gehen Sie durch die Straßen, bevor die Inseratsuche Ihre Optionen einengt.',
showcaseScoutBullet2:
'Testen Sie den Pendelweg von einer echten Haustür, nicht nur anhand eines Bezirksnamens.',
showcaseScoutBullet3: 'Vergleichen Sie Besichtigungen mit belastbaren Daten im Rücken.',
showcaseStep1Tab: 'Filtern',
showcaseStep1Title: 'Festlegen, was funktionieren muss',
showcaseStep1Title: 'Aus vagen Wünschen eine präzise Suche machen',
showcaseStep1Body:
'Fügen Sie Budget, Pendelweg, Schulen, Sicherheit, Lärm und lokale Details hinzu. Sehen Sie zu, wie die falschen Postleitzahlen herausfallen.',
'Legen Sie fest, was zählt, und sehen Sie genau, wie viele unpassende Postleitzahlen jede Anforderung aus Ihrer Suche ausschließt.',
showcaseStep1Chip1: 'Ruhige Straßen',
showcaseStep1Chip2: 'Gute Grundschulen in der Nähe',
showcaseStep1Chip2: 'Top-Grundschulen',
showcaseStep1Chip3: 'Unter £500k',
showcaseStep1VennCenter: 'Postleitzahlen, die alle drei erfüllen',
showcaseStep2Tab: 'Abgleichen',
showcaseStep2Title: 'Sehen Sie die Orte, die übrig bleiben',
showcaseStep2Title: 'Die Karte zeigt Orte, die Sie nicht eingegeben hätten',
showcaseStep2Body:
'Suchen Sie nach praktischen Kriterien, nicht nach vertrauten Namen. Die Karte zeigt Postleitzahlen-Cluster, die Sie zuerst prüfen sollten.',
'Durchsuchen Sie England nach Passung, statt mit vertrauten Gebietsnamen zu beginnen. Verborgene Ecken werden sichtbar, bevor Immobilienportale Ihre Suche einengen.',
showcaseStep2Region: 'Großraum London',
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseStep2ClustersLabel: 'Passende Gruppen',
showcaseStep3Tab: 'Prüfen',
showcaseStep3Title: 'Prüfen Sie die Daten',
showcaseStep3Title: 'Prüfen, warum eine Postleitzahl passt',
showcaseStep3Body:
'Öffnen Sie eine Postleitzahl und sehen Sie Preis, Pendelweg, Schulen, Kriminalität, Breitband und Kompromisse, bevor Sie besichtigen.',
showcaseStep3HeaderArea: 'Vorausgewählte Postleitzahl',
showcaseStep3HeaderFit: 'Was passt',
'Öffnen Sie ein passendes Gebiet und prüfen Sie Preise, Sicherheit, Schulen, Breitband und Kompromisse in einem Bereich, bevor Sie ein Wochenende dort verbringen.',
showcaseStep3HeaderArea: 'Ihre perfekte Postleitzahl',
showcaseStep3HeaderFit: 'Nachbarschaftsbelege',
showcaseStep3Stat1Label: 'Verkaufspreis-Trend',
showcaseStep3Stat2Label: 'Kriminalität',
showcaseStep3Stat2Value: 'Unter dem Bezirksdurchschnitt',
@ -964,35 +975,34 @@ const de: Translations = {
showcaseStep3Stat5Label: 'Grundschulen',
showcaseStep3Stat5Value: '3 „Hervorragend“ innerhalb von 1 Meile',
showcaseStep4Tab: 'Erkunden',
showcaseStep4Title: 'Nehmen Sie die Auswahlliste mit auf die Straße',
showcaseStep4Title: 'Selbst vor Ort prüfen',
showcaseStep4Body:
'Exportieren Sie die prüfenswerten Postleitzahlen, testen Sie den Pendelweg, laufen Sie die Straßen ab und vergleichen Sie Besichtigungen mit gespeichertem Kontext.',
'Nehmen Sie drei fundierte Ausgangspunkte mit in die echte Welt. Laufen Sie durch die Straßen, testen Sie den Pendelweg und vergleichen Sie Besichtigungen mit Kontext.',
showcaseStep4FileName: 'areas-to-scout.xlsx',
showcaseStep4ExportLabel: 'Nach Excel exportieren',
showcaseStep4ColPostcode: 'Postleitzahl',
showcaseStep4ColScore: 'Passung',
showcaseStep4ColCommute: 'Pendeln',
showcaseStep4ColPrice: 'Median-Verkaufspreis',
showcaseStep4Conclusion:
'Exportieren Sie eine Auswahlliste und beginnen Sie, die Straßen zu prüfen.',
statProperties: 'Verkäufe laut HM Land Registry',
statFilters: 'Möglichkeiten, die Karte einzugrenzen',
showcaseStep4ColPrice: 'Median verkauft',
showcaseStep4Conclusion: 'Von hier aus können Sie Ihre Suche beginnen.',
statProperties: 'historische Verkäufe',
statFilters: 'kombinierbare Filter',
statEvery: 'Jede',
statPostcodeInEngland: 'aktive Postleitzahl in England',
ourPhilosophy: 'Beginnen Sie nicht länger mit Orten, die Sie schon kennen.',
statPostcodeInEngland: 'Postleitzahl in England',
ourPhilosophy: 'Beginnen Sie mit dem, was zählt, und finden Sie die passende Postleitzahl',
philosophyP1:
'Die meisten Suchen beginnen mit einem Ortsnamen und hoffen dann, dass passende Wohnungen auftauchen. Das überspringt die schwierigere Frage: Welche Orte lohnen sich wirklich für die Suche?',
'Die meisten Immobilienseiten fragen, wo Sie wohnen möchten. In London ist das besonders schwierig, aber das gleiche Problem gibt es in ganz England: Käufer starten mit wenigen bekannten Orten und prüfen dann Pendelzeit, Schulen, Kriminalität, Street View, Breitband und Verkaufspreise in getrennten Tabs.',
philosophyP2:
'Perfect Postcode beginnt vor dem Immobilienportal. Legen Sie fest, was ein Ort leisten muss, und sehen Sie zuerst die Postleitzahlen, die Ihre Aufmerksamkeit verdienen.',
'Perfect Postcode dreht die Suche um. Sagen Sie der Karte, was zählt, und sie zeigt passende Postleitzahlen mit nachvollziehbaren Gründen. Erst Daten, dann vor Ort das Gefühl prüfen.',
streetTitle: 'Orte ändern sich von Straße zu Straße',
streetIntro:
'Die richtige Bahnhofsseite, eine laute Straße oder ein einzelner Schuleinzugsbereich können die Suche verändern. Gebietsnamen ebnen all das ein.',
streetCard1Title: 'Raus aus der Falle vertrauter Namen',
'Große Gebietsnamen verdecken die Details, die zählen: Bahnhofsseite, Straßenlärm, Schulmix, genaue Pendelzeit und echte Verkaufspreise.',
streetCard1Title: 'Finden Sie Gegenden, die Sie übersehen hätten',
streetCard1Body:
'Finden Sie passende Postleitzahlen außerhalb der Orte, die bereits auf Ihrer Liste stehen.',
streetCard2Title: 'Kennen Sie die Kompromisse, bevor Sie losgehen',
'Entdecken Sie Postleitzahlen, die Ihren Anforderungen entsprechen, statt sich nur auf bekannte Namen, Empfehlungen oder Hype zu verlassen.',
streetCard2Title: 'Sehen Sie Kompromisse vor Besichtigungen',
streetCard2Body:
'Prüfen Sie Preis, Pendelweg, Lärm, Schulen, Sicherheit, Breitband und nahe gelegene Ausstattung, bevor Sie Besichtigungen buchen.',
'Vergleichen Sie Preis, Platz, Pendelzeit, Sicherheit, Schulen, Breitband, Lärm und Energieeffizienz, bevor Sie Wochenenden mit Besichtigungen verbringen.',
othersVs: 'Andere vs',
checkMyPostcode: 'Immobilienportale',
areaGuides: 'Postleitzahl-Berichte',
@ -1002,11 +1012,11 @@ const de: Translations = {
compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband, Ausstattung)',
compPropertyData: 'Historie auf Immobilienebene',
compPropertyDataSub: '(Verkaufspreise, EPC, Wohnfläche, Schätzwert)',
compFilters: 'Budget, Pendelweg, Schulen, Sicherheit und lokale Daten zusammen',
compFiltersSub: '(Budget + Pendelweg + Schulen + Sicherheit + lokaler Kontext)',
ctaTitle: 'Finden Sie heraus, wo Sie suchen sollten, bevor Sie Besichtigungen buchen.',
compFilters: '56 Filter, die zusammenarbeiten',
compFiltersSub: '(nicht eine Postleitzahl oder ein Inserat nach dem anderen)',
ctaTitle: 'Hören Sie auf zu raten, wo Sie kaufen sollen.',
ctaDescription:
'Erstellen Sie eine Postleitzahlen-Auswahlliste aus den Dingen, die zählen, und prüfen Sie dann die Straßen persönlich.',
'Erstellen Sie eine Auswahlliste von Postleitzahlen, die zu Ihrem echten Leben passen, und prüfen Sie sie dann vor Ort.',
},
// ── Pricing Page ───────────────────────────────────
@ -1167,11 +1177,11 @@ const de: Translations = {
'Zeiten für öffentliche Verkehrsmittel basieren auf einem Pendelweg an Wochentagen morgens, mit Abfahrten zwischen 07:30 und 08:30. Die normale Einstellung zeigt eine typische Fahrt in diesem Zeitraum. Es sind Planungswerte, keine Echtzeitverspätungen, Verkehrsmeldungen oder kurzfristigen Gleiswechsel.',
faqCommute3Q: 'Wann sollte ich die Bestfall-Schaltfläche nutzen?',
faqCommute3A:
'Nutzen Sie die Bestfall-Schaltfläche für öffentliche Verkehrsmittel, wenn Sie die Fahrt mit gut gewählter Abfahrt und guten Anschlüssen sehen möchten. Lassen Sie sie ausgeschaltet, wenn Sie den Alltagsvergleich möchten.',
'Nutzen Sie die Bestfall-Schaltfläche bei ÖPNV-Suchen, wenn Sie die Fahrt mit gut gewählter Abfahrt und guten Anschlüssen sehen möchten. Lassen Sie sie für den Alltagsvergleich ausgeschaltet, da die normale Einstellung näher daran liegt, was Sie an den meisten Tagen erwarten sollten.',
// FAQ items — Budget and Value
faqBudget1Q: 'Wie schätzen Sie aktuelle Immobilienpreise?',
faqBudget1A:
'Die Schätzung beginnt mit dem letzten bei HM Land Registry erfassten Verkaufspreis. Wir bringen diesen Verkauf näher an den heutigen Markt, indem wir ansehen, wie sich ähnliche Häuser entwickelt haben, besonders Häuser desselben Typs in der Nähe. Wenn es nur wenige lokale Verkäufe gibt, stützt sich die Schätzung stärker auf Trends in einem größeren Gebiet. Danach wird sie mit nahegelegenen jüngeren Verkäufen und der Wohnfläche abgeglichen.',
'Die Schätzung beginnt mit dem letzten bei HM Land Registry erfassten Verkaufspreis. Wir bringen diesen Verkauf näher an den heutigen Markt, indem wir betrachten, wie sich ähnliche Häuser entwickelt haben, besonders Häuser desselben Typs in der Nähe. Wenn es nur wenige lokale Verkäufe gibt, stützt sich die Schätzung stärker auf weiterreichende Gebietstrends. Anschließend wird sie mit nahegelegenen jüngeren Verkäufen und der Wohnfläche abgeglichen, damit das Ergebnis für Vergleiche nützlich ist.',
faqBudget2Q: 'Warum den geschätzten aktuellen Preis statt des letzten Verkaufspreises nutzen?',
faqBudget2A:
'Der letzte Verkaufspreis kann Jahre oder Jahrzehnte alt sein, und Angebotspreise zeigen nur, was heute zum Verkauf steht. Der geschätzte aktuelle Preis bringt ältere Verkäufe näher an den heutigen Markt, damit Sie mehr Häuser vergleichen und Gebiete mit möglichem Wert erkennen können, bevor passende Inserate erscheinen. Nutzen Sie ihn als Orientierung für Ihre Auswahlliste, nicht als Bankbewertung.',
@ -1195,17 +1205,17 @@ const de: Translations = {
'Wie vermeide ich eine laute Straße, ohne Pendelzeit oder Breitbandqualität zu verlieren?',
faqEnv1A:
'Filtern Sie nach Straßenlärm und lassen Sie Pendelzeit, Breitband, Preis und Hausfilter aktiv. Sie können die Karte nach einem Merkmal einfärben, während die anderen Filter die Auswahlliste realistisch halten.',
faqEnv2Q: 'Zeigt es Hochwasser-, Senkungs- oder Gutachterrisiken?',
faqEnv2Q: 'Zeigt es Hochwasserrisiko, Bodensenkungen oder Gutachterprobleme?',
faqEnv2A:
'Nicht heute. Wir zeigen Straßenlärm, Energieklasse, Baualter und die lokale Umgebung rund um die Postleitzahl. Hochwasserrisiko, rechtliche Fragen, bauliche Mängel, Finanzierungsthemen und Gutachten müssen vor dem Kauf separat geprüft werden.',
faqEnv3Q: 'Welche laufenden Kosten kann ich vor einer Besichtigung prüfen?',
faqEnv3A:
'Sie können vor der Besichtigung Energieklasse, Wohnfläche, Baualter, kommunales Steuergebiet, Breitband und Lärm prüfen. Das sagt Ihre genauen Rechnungen nicht voraus, hilft aber, offensichtliche Fehlgriffe früh auszusortieren.',
'Sie können vor der Besichtigung Energieklasse, Wohnfläche, Baualter, Council-Tax-Gebiet, Breitband und Lärm prüfen. Das sagt Ihre genauen Rechnungen nicht voraus, hilft aber, offensichtliche Fehlgriffe früh zu vermeiden.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Sollte ich das vor oder nach Rightmove nutzen?',
faqDueDiligence1A:
'Nutzen Sie Perfect Postcode vor und parallel zu Inseratsseiten. Rightmove, Zoopla und OnTheMarket bleiben die Orte für aktuell verfügbare Häuser, Fotos, Makler, Besichtigungen und Benachrichtigungen. Perfect Postcode hilft zu entscheiden, welche Postleitzahlen die Suche wert sind.',
faqDueDiligence2Q: 'Kann ich nach Garten, Garage, Grundriss oder Inseratstext filtern?',
faqDueDiligence2Q: 'Warum kann ich nicht nach Garten, Garage oder Grundriss filtern?',
faqDueDiligence2A:
'Diese Details sind nicht für jedes Haus zuverlässig verfügbar. Perfect Postcode kann nach Wohnfläche, Haustyp, Eigentumsform, Energieklasse, Verkaufspreisen und lokalen Informationen filtern. Gärten, Garagen, Ausrichtung, Raumaufteilung und Maklerformulierungen müssen weiterhin in der Anzeige und bei der Besichtigung geprüft werden.',
faqDueDiligence3Q: 'Kann ich Preisreduzierungen oder die Online-Dauer eines Inserats sehen?',
@ -1213,11 +1223,11 @@ const de: Translations = {
'Derzeit nicht. Perfect Postcode basiert auf Verkaufspreisen, Energieklassen, Postleitzahlen, Reisezeiten und Nachbarschaftsinformationen, nicht auf Echtzeitänderungen in Inseraten. Sie können aber Verkaufshistorie, geschätzten aktuellen Wert und Preis pro m² nutzen, um einzuschätzen, ob ein Angebotspreis hoch wirkt.',
faqDueDiligence4Q: 'Was sollte ich vor einem Angebot trotzdem prüfen?',
faqDueDiligence4A:
'Nutzen Sie Perfect Postcode, um Gebiet und wahrscheinlichen Wert zu prüfen, und bestätigen Sie dann die Inseratsdetails vor einem Angebot. Prüfen Sie außerdem Eigentumsform, Details zum Erbbaurecht, Nebenkosten, Planungshistorie, Hochwasserrisiko, rechtliche Fragen, Hypothekenanforderungen und Gutachten.',
'Nutzen Sie Perfect Postcode, um Gebiet und wahrscheinlichen Wert zu prüfen, und bestätigen Sie dann die Inseratsdetails vor einem Angebot. Prüfen Sie außerdem die Eigentumsform, Details zum Erbbaurecht, Nebenkosten, Planungshistorie, Hochwasserrisiko, rechtliche Fragen, Hypothekenanforderungen und Gutachten.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Speichern Sie personenbezogene Daten über mich?',
faqPrivacy1A:
'Die Immobilien- und Nachbarschaftsinformationen enthalten keine persönlichen Angaben über Sie. Wenn Sie ein Konto erstellen, speichern wir nur, was für den Dienst nötig ist, etwa E-Mail-Adresse, Zugangsstatus, Newsletter-Auswahl, gespeicherte Suchen, gespeicherte Immobilien und von Stripe verwaltete Zahlungen. Diese Kontodaten behandeln wir nach britischem Datenschutzrecht.',
'Die Immobilien- und Nachbarschaftsinformationen enthalten keine persönlichen Angaben über Sie. Wenn Sie ein Konto erstellen, speichern wir nur, was für den Dienst nötig ist, etwa E-Mail-Adresse, Zugangsstatus, Newsletter-Auswahl, gespeicherte Suchen, geteilte Links und von Stripe verwaltete Zahlungsdaten. Diese Kontodaten behandeln wir nach britischem Datenschutzrecht.',
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'Was zeigt das, was Immobilienportale normalerweise nicht zeigen?',
faqWhy1A:
@ -1242,13 +1252,13 @@ const de: Translations = {
// FAQ items — Tips and Tricks
faqTips1Q: 'Wie sehe ich eine Filtervorschau auf der Karte?',
faqTips1A:
'Klicken Sie auf das Augen-Symbol neben einem Filter oder Merkmal, um die Karte nach diesem Punkt einzufärben. Ihre aktiven Filter bleiben bestehen, sodass Sie schnell eine Sache wie Preis, Pendelzeit, Schulen, Kriminalität oder Lärm vergleichen können, ohne die Auswahlliste zu ändern.',
'Klicken Sie auf „Färben“ neben einem Filter oder Merkmal, um die Karte nach diesem Punkt einzufärben. Ihre aktiven Filter bleiben bestehen, sodass Sie schnell eine Sache wie Preis, Pendelzeit, Schulen, Kriminalität oder Lärm vergleichen können, ohne die Auswahlliste zu ändern.',
faqTips2Q: 'Wie erfahre ich, was ein Filter bedeutet?',
faqTips2A:
'Klicken Sie auf den i-Infobutton neben einem Filter oder Merkmal, um eine kurze Erklärung zu sehen, was es bedeutet und wie Sie es lesen. Einige Teile der Karte, etwa Reisezeitkarten, haben ebenfalls einen eigenen Infobutton.',
'Klicken Sie auf „Info“ neben einem Filter oder Merkmal, um eine kurze Erklärung zu sehen, was es bedeutet und wie Sie es lesen. Einige Teile der Karte, etwa Reisezeitkarten, haben ebenfalls eine eigene Datenerklärung.',
faqTips3Q: 'Wie aktualisiere ich die Kartenfarben?',
faqTips3A:
'Wenn eine Augen-Vorschau die Karte einfärbt, nutzen Sie Farbskala zurücksetzen in der Legende, um die Farben für die aktuell angezeigten Ergebnisse zu aktualisieren. Das ist nach Verschieben, Zoomen oder geänderten Filtern nützlich.',
'Wenn ein Merkmal die Karte einfärbt, nutzen Sie „Farbskala zurücksetzen“ in der Kartenlegende, um die Farben für die aktuell angezeigten Ergebnisse zu aktualisieren. Das ist nach Verschieben, Zoomen oder geänderten Filtern nützlich.',
},
// ── Account Page ───────────────────────────────────
@ -1277,6 +1287,8 @@ const de: Translations = {
deleteSearch: 'Suche löschen',
deleteSearchConfirm:
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
isBeingUpdated: '<strong>{{name}}</strong> wird aktualisiert',
updating: 'Aktualisiere...',
},
// ── Invites Page ───────────────────────────────────
@ -1348,7 +1360,7 @@ const de: Translations = {
tutorial: {
step1Title: 'Sagen Sie der Karte, was zählt',
step1Content:
'Legen Sie Budget, Pendelzeitlimit, Schulqualität, Kriminalitätsschwelle, Lärmtoleranz, Breitbandbedarf oder alles fest, was Ihnen wichtig ist. Nur passende Gebiete bleiben hervorgehoben. Nutzen Sie das Augensymbol, um nach einem beliebigen Merkmal einzufärben.',
'Legen Sie Budget, Pendelzeitlimit, Schulqualität, Kriminalitätsschwelle, Lärmtoleranz, Breitbandbedarf oder alles fest, was Ihnen wichtig ist. Nur passende Gebiete bleiben hervorgehoben. Nutzen Sie „Färben“, um die Karte nach einem beliebigen Merkmal einzufärben.',
step2Title: 'Oder einfach beschreiben',
step2Content:
'Beschreiben Sie in Alltagssprache, was Sie möchten, zum Beispiel „ruhige Gegend nahe guter Schulen unter £400k“, und wir richten die Filter für Sie ein.',
@ -1375,6 +1387,7 @@ const de: Translations = {
'Property prices': 'Immobilienpreise',
Transport: 'Verkehr',
Education: 'Bildung',
'Defining characteristics': 'Prägende Merkmale',
'Area development': 'Gebietsentwicklung',
Crime: 'Kriminalität',
Neighbours: 'Nachbarn',

View file

@ -2,6 +2,7 @@ const en = {
// ── Common ──────────────────────────────────────────
common: {
save: 'Save',
update: 'Update',
cancel: 'Cancel',
close: 'Close',
delete: 'Delete',
@ -652,8 +653,11 @@ const en = {
clearAll: 'Clear all',
clearAllTitle: 'Clear all filters?',
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
clearAllUpdatePrompt: 'Update <strong>{{name}}</strong> with your current filters before clearing?',
saveAndClear: 'Save & Clear',
updateAndClear: 'Update & Clear',
clearWithoutSaving: 'Clear without saving',
clearWithoutUpdating: 'Clear without updating',
filtersOut: 'filters out {{value}}',
schoolType: 'School type',
schoolRating: 'School rating',
@ -802,10 +806,12 @@ const en = {
showAllStatsFallback:
'Switch to all properties to inspect this area without the active filters.',
showAllStats: 'Show all properties',
closestBlockingFilters: 'Closest filters excluding this area',
closestBlockingFilters: 'Closest changes to include this area',
lowerMinTo: 'Lower minimum to {{value}}',
raiseMaxTo: 'Raise maximum to {{value}}',
allowCategory: 'Allow {{value}}',
missingFilterValue: 'No value for this filter; remove it',
noFilterDataShort: 'No data',
travelTo: 'Travel to {{destination}}',
viewProperties: 'View {{count}} Properties',
viewPropertiesShort: 'View properties',
@ -871,17 +877,18 @@ const en = {
// ── Home Page ──────────────────────────────────────
home: {
heroEyebrow: 'Find where to look first',
heroTitle1: 'Stop searching',
heroTitle2: 'the wrong places',
heroTitle3: 'Before listings narrow your search.',
heroSubtitle: 'Find postcodes where your budget, commute, and daily life line up.',
heroEyebrow: 'For buyers asking “where should I even look?”',
heroTitle1: 'Find the postcodes that',
heroTitle2: 'fit your life',
heroTitle3: 'Not just the areas you already know.',
heroSubtitle:
'From London boroughs to commuter towns and regional cities, England has too many places to research one by one.',
heroDescription:
'Perfect Postcode filters every postcode first, so you only chase viewings in places that work.',
exploreTheMap: 'Show me where to look',
seeTheDifference: 'Watch demo',
productDemoLabel: 'See how to find where to look first',
playProductDemo: 'Play the where-to-look demo',
'Set your budget, commute, schools, safety, noise, broadband, and lifestyle needs. Perfect Postcode scans Englands postcodes and reveals the places that actually fit, including areas youd never have typed into a listing portal.',
exploreTheMap: 'Find my matching postcodes',
seeTheDifference: 'See how it works',
productDemoLabel: 'Perfect Postcode product demo',
playProductDemo: 'Play Perfect Postcode product demo',
scrollToProductDemo: 'Scroll to product demo',
showcaseHeader: 'How it works',
showcaseContext: 'How Perfect Postcode works',
@ -889,43 +896,43 @@ const en = {
showcaseFeatureNoiseShort: 'Noise',
showcaseFeatureSchoolsShort: 'Schools',
showcaseFeatureTravelShort: 'Travel',
showcaseGoodPrimariesNearby: '{{count}}+ Good or Outstanding primary schools nearby',
showcaseWithinRail: 'Within {{count}} min of a station',
showcaseMatchingHomesLabel: 'Matching postcodes',
showcaseMatchingHomes: '{{value}} matching postcodes',
showcaseGoodPrimariesNearby: '{{count}}+ good primaries nearby',
showcaseWithinRail: 'Within {{count}} min of rail',
showcaseMatchingHomesLabel: 'Matching homes',
showcaseMatchingHomes: '{{value}} matching homes',
showcaseMedianPrice: '{{value}} median',
showcaseJourneyRoutes: 'Journey routes',
showcaseNearby: '{{value}} nearby',
showcasePoliticalVoteShare: 'Political vote share',
showcaseLotsMore: 'More neighbourhood data',
showcaseLotsMore: '...and lots more',
showcaseMinutes: '{{count}} min',
showcaseSendShortlist: 'Send the shortlist',
showcaseDownloadXlsx: 'Download .xlsx',
showcaseTopThree: 'Top 3',
showcaseScoutBullet1: 'Check the street before you commit to listing alerts.',
showcaseScoutBullet1: 'Walk the streets before the listing search narrows your options.',
showcaseScoutBullet2: 'Test the commute from a real front door, not a borough name.',
showcaseScoutBullet3: 'Compare viewings with evidence already saved.',
showcaseScoutBullet3: 'Compare viewings with evidence already in hand.',
showcaseStep1Tab: 'Filter',
showcaseStep1Title: 'Set what has to work',
showcaseStep1Title: 'Turn vague needs into a tight search',
showcaseStep1Body:
'Add budget, commute, schools, safety, noise, and local details. Watch the wrong postcodes drop out.',
'Set what matters and see exactly how many wrong-fit postcodes each requirement keeps out of your search.',
showcaseStep1Chip1: 'Quiet streets',
showcaseStep1Chip2: 'Good primaries nearby',
showcaseStep1Chip2: 'Top-rated primaries',
showcaseStep1Chip3: 'Under £500k',
showcaseStep1VennCenter: 'Postcodes that meet all three',
showcaseStep2Tab: 'Match',
showcaseStep2Title: 'See the places left standing',
showcaseStep2Title: 'Let the map surface places you wouldnt have typed',
showcaseStep2Body:
'Search by practical checks, not familiar names. The map shows postcode clusters worth checking first.',
'Scan England by fit instead of starting from familiar area names. Hidden pockets become visible before listing portals narrow your imagination.',
showcaseStep2Region: 'Greater London',
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseStep2ClustersLabel: 'Matching clusters',
showcaseStep3Tab: 'Inspect',
showcaseStep3Title: 'Check the evidence',
showcaseStep3Title: 'Inspect why a postcode made the cut',
showcaseStep3Body:
'Open a postcode and see the price, commute, schools, crime, broadband, and trade-offs before you visit.',
showcaseStep3HeaderArea: 'Shortlisted postcode',
showcaseStep3HeaderFit: 'What works',
'Open any matching area and check prices, safety, schools, broadband, and trade-offs in one pane before you spend a weekend there.',
showcaseStep3HeaderArea: 'Your perfect postcode',
showcaseStep3HeaderFit: 'Neighbourhood evidence',
showcaseStep3Stat1Label: 'Sold price trend',
showcaseStep3Stat2Label: 'Crime rate',
showcaseStep3Stat2Value: 'Below borough avg.',
@ -933,49 +940,50 @@ const en = {
showcaseStep3Stat4Label: 'Broadband',
showcaseStep3Stat4Value: '1 Gbps available',
showcaseStep3Stat5Label: 'Primary schools',
showcaseStep3Stat5Value: '3 Outstanding within 1 mile',
showcaseStep3Stat5Value: '3 outstanding within 1 mile',
showcaseStep4Tab: 'Scout',
showcaseStep4Title: 'Take the shortlist to the streets',
showcaseStep4Title: 'Scout it out yourself',
showcaseStep4Body:
'Export the postcodes worth checking, test the commute, walk the roads, and compare viewings with context saved.',
'Take three grounded starting points into the real world. Walk the streets, test the commute, and compare viewings with context.',
showcaseStep4FileName: 'areas-to-scout.xlsx',
showcaseStep4ExportLabel: 'Export to Excel',
showcaseStep4ColPostcode: 'Postcode',
showcaseStep4ColScore: 'Match',
showcaseStep4ColScore: 'Fit',
showcaseStep4ColCommute: 'Commute',
showcaseStep4ColPrice: 'Median sold price',
showcaseStep4Conclusion: 'Export a shortlist and start checking streets.',
statProperties: 'HM Land Registry sales',
statFilters: 'ways to narrow the map',
showcaseStep4ColPrice: 'Median sold',
showcaseStep4Conclusion: 'You can start your journey from here.',
statProperties: 'historical sales',
statFilters: 'combinable filters',
statEvery: 'Every',
statPostcodeInEngland: 'active postcode in England',
ourPhilosophy: 'Stop starting with towns you already know.',
statPostcodeInEngland: 'postcode in England',
ourPhilosophy: 'Start with what matters, then find the right postcode',
philosophyP1:
'Most searches start with a place name, then hope the right homes appear. That skips the harder question: which places are actually worth searching?',
'Most property sites ask where you want to live. In London thats painfully hard, but the same problem shows up across England: buyers choose from the few places they know, then cross-check commute tools, Ofsted, police data, Street View, broadband checkers, and sold prices in separate tabs.',
philosophyP2:
'Perfect Postcode starts before the listing site. Set the things a place must support, then see the postcodes that deserve your attention first.',
'Perfect Postcode flips the search. Tell the map what matters and it shows the postcodes that qualify, with evidence for why theyre worth inspecting. Data first, then go test the vibe.',
streetTitle: 'Places change street by street',
streetIntro:
'The right side of a station, a noisy road, or one school catchment can change the search. Area names flatten all of that.',
streetCard1Title: 'Escape the familiar-name trap',
streetCard1Body: 'Find postcode-level matches outside the places already on your list.',
streetCard2Title: 'Know the trade-offs before you go',
'Broad area names hide the details that matter: the station side, the road noise, the school mix, the exact commute, and what similar homes actually sold for.',
streetCard1Title: 'Find areas you may have missed',
streetCard1Body:
'Surface postcodes that match your requirements instead of relying on familiar names, friend recommendations, or “up-and-coming” hype.',
streetCard2Title: 'See the trade-offs before viewings',
streetCard2Body:
'Check price, commute, noise, schools, safety, broadband, and nearby amenities before booking viewings.',
othersVs: 'Other tools vs',
checkMyPostcode: 'Listing sites',
areaGuides: 'Postcode checkers',
compSearchWithout: 'Find areas before you know their names',
'Compare price, space, commute, safety, schools, broadband, noise, and energy ratings before you spend weekends travelling between viewings.',
othersVs: 'Others vs',
checkMyPostcode: 'Listing portals',
areaGuides: 'Postcode reports',
compSearchWithout: 'Discover areas before you know their names',
compSearchWithoutSub: '(requirements first, location second)',
compAreaData: 'Neighbourhood evidence in one place',
compAreaData: 'Postcode-level neighbourhood evidence',
compAreaDataSub: '(crime, schools, noise, broadband, amenities)',
compPropertyData: 'Street-level property context',
compPropertyData: 'Property-level history',
compPropertyDataSub: '(sold prices, EPC, floor area, estimated value)',
compFilters: 'Budget, commute, schools, safety, and local data together',
compFiltersSub: '(budget + commute + schools + safety + local context)',
ctaTitle: 'Find where to look before you book viewings.',
compFilters: '56 filters working together',
compFiltersSub: '(not one postcode or one listing at a time)',
ctaTitle: 'Stop guessing where to buy.',
ctaDescription:
'Build a postcode shortlist from the things that matter, then check the streets in person.',
'Build a shortlist of postcodes that fit your actual life, then test them in person.',
},
// ── Pricing Page ───────────────────────────────────
@ -1241,6 +1249,8 @@ const en = {
notesPlaceholder: 'Jot down your thoughts...',
deleteSearch: 'Delete search',
deleteSearchConfirm: 'Are you sure you want to delete this saved search? This cant be undone.',
isBeingUpdated: '<strong>{{name}}</strong> is being updated',
updating: 'Updating...',
},
// ── Invites Page ───────────────────────────────────
@ -1338,6 +1348,7 @@ const en = {
'Property prices': 'Property prices',
Transport: 'Transport',
Education: 'Education',
'Defining characteristics': 'Defining characteristics',
'Area development': 'Area development',
Crime: 'Crime',
Neighbours: 'Neighbours',

View file

@ -4,6 +4,7 @@ const fr: Translations = {
// ── Common ──────────────────────────────────────────
common: {
save: 'Enregistrer',
update: 'Mettre à jour',
cancel: 'Annuler',
close: 'Fermer',
delete: 'Supprimer',
@ -126,12 +127,12 @@ const fr: Translations = {
'Compare nearby postcodes using the same criteria instead of relying on area reputation.':
'Comparez les codes postaux à proximité en utilisant les mêmes critères au lieu de vous fier à la réputation de la zone.',
'Use the results as a shortlist for listing alerts, local research, and viewings.':
'Utilisez les résultats comme liste restreinte pour répertorier les alertes, les recherches locales et les visites.',
'Utilisez les résultats comme liste restreinte pour les alertes dannonces, les recherches locales et les visites.',
'Separate cheap from good value': 'Séparez le bon marché du bon rapport qualité-prix',
'A lower price can reflect smaller homes, weaker transport, more noise, or fewer local services. The map keeps those trade-offs visible so the cheapest postcode isnt automatically treated as the best option.':
'Un prix inférieur peut refléter des logements plus petits, des transports plus faibles, plus de bruit ou moins de services locaux. La carte garde ces compromis visibles, de sorte que le code postal le moins cher nest pas automatiquement traité comme la meilleure option.',
'Start from area value, not listing availability':
'Commencer à partir de la valeur de la zone, sans lister la disponibilité',
'Partir de la valeur de la zone, et non de la disponibilité des annonces',
'Listing portals only show homes for sale today. A postcode-level property price map lets you compare wider areas, understand local price patterns, and avoid missing places where the next suitable listing might appear.':
"Les portails dannonces affichent uniquement les maisons à vendre aujourdhui. Une carte des prix immobiliers au niveau du code postal vous permet de comparer des zones plus larges, de comprendre les modèles de prix locaux et d'éviter de manquer des endroits où la prochaine annonce appropriée pourrait apparaître.",
'Use prices alongside real constraints': 'Utiliser les prix avec des contraintes réelles',
@ -140,7 +141,7 @@ const fr: Translations = {
'What the price data is for': 'À quoi servent les données de prix',
'Use the map to compare areas and spot search candidates. It isnt a valuation, mortgage decision, survey, legal search, or live listing feed.':
'Utilisez la carte pour comparer les zones et repérer les candidats à la recherche. Il ne sagit pas dune évaluation, dune décision hypothécaire, dune enquête, dune recherche juridique ou dun flux dannonces en direct.',
'How to validate a promising area': 'Comment valider un domaine prometteur',
'How to validate a promising area': 'Comment valider une zone prometteuse',
'Once a postcode looks promising, check current listings, sold-price comparables, agent details, flood searches, legal packs, surveys, and local authority information before making a decision.':
"Une fois qu'un code postal semble prometteur, vérifiez les annonces actuelles, les prix de vente comparables, les détails de l'agent, les recherches d'inondations, les dossiers juridiques, les enquêtes et les informations des autorités locales avant de prendre une décision.",
'Is this a replacement for Rightmove or Zoopla?':
@ -166,7 +167,7 @@ const fr: Translations = {
'Postcode checker': 'Vérificateur de code postal',
'Check one postcode before you spend time on a viewing.':
'Vérifiez un code postal avant de consacrer du temps à une visite.',
'Explore the property map': 'Explorez la carte de la propriété',
'Explore the property map': 'Explorez la carte immobilière',
'Postcode property search': 'Recherche de propriété par code postal',
'Find postcodes that match your property search criteria':
'Trouvez les codes postaux qui correspondent à vos critères de recherche de propriété',
@ -179,16 +180,16 @@ const fr: Translations = {
'Filter England-wide postcode data from one map.':
'Filtrez les données des codes postaux de toute lAngleterre à partir dune seule carte.',
'Shortlist unfamiliar areas with comparable evidence.':
'Présélectionnez les domaines inconnus avec des preuves comparables.',
'Présélectionnez des zones inconnues avec des éléments de comparaison.',
'Save and share search areas before booking viewings.':
'Enregistrez et partagez les zones de recherche avant de réserver des visites.',
'Turn a broad brief into postcode candidates':
'Transformez un dossier général en candidats au code postal',
'Transformez un cahier des charges général en codes postaux candidats',
'Enter the practical constraints first: budget, property size, tenure, travel time, school needs, broadband, and tolerance for road noise or crime levels. The map removes places that fail those constraints and keeps the remaining options comparable.':
"Saisissez d'abord les contraintes pratiques : budget, taille de la propriété, mode d'occupation, temps de trajet, besoins scolaires, haut débit et tolérance au bruit routier ou aux niveaux de criminalité. La carte supprime les endroits qui ne respectent pas ces contraintes et conserve les options restantes comparables.",
'Relax one constraint at a time': 'Assouplir une contrainte à la fois',
'When the search becomes too narrow, loosen a single filter and watch which postcodes reappear. This makes compromise explicit instead of relying on guesswork.':
'Lorsque la recherche devient trop étroite, supprimez un seul filtre et observez quels codes postaux réapparaissent. Cela rend le compromis explicite au lieu de sappuyer sur des conjectures.',
'Lorsque la recherche devient trop étroite, assouplissez un seul filtre et observez quels codes postaux réapparaissent. Cela rend le compromis explicite au lieu de sappuyer sur des conjectures.',
'Turn vague areas into specific postcodes':
'Transformez des zones vagues en codes postaux spécifiques',
'Broad town or borough searches hide large differences between streets. Perfect Postcode helps you move from a general area to postcodes that satisfy your hard requirements.':
@ -202,7 +203,7 @@ const fr: Translations = {
"Deux codes postaux proches peuvent différer en termes d'écoles, de bruit routier, d'accès aux transports, de composition immobilière et de prix. La comparaison au niveau du code postal réduit la probabilité de traiter une ville entière comme un seul marché uniforme.",
'How to use the results': 'Comment utiliser les résultats',
'Treat matching postcodes as a research queue: check live listings, visit streets, confirm schools and admissions, and review current official sources.':
"Traitez les codes postaux correspondants comme une file d'attente de recherche : vérifiez les listes en direct, visitez les rues, confirmez les écoles et les admissions et consultez les sources officielles actuelles.",
"Traitez les codes postaux correspondants comme une file d'attente de recherche : vérifiez les annonces en cours, visitez les rues, confirmez les écoles et les admissions et consultez les sources officielles actuelles.",
'Can I save a postcode property search?':
'Puis-je enregistrer une recherche de propriété par code postal ?',
'Yes. Licensed users can save searches and return to them later. Saved searches are designed for shortlists and comparison notes.':
@ -218,11 +219,11 @@ const fr: Translations = {
'A regional guide for narrowing a broad search around Greater Manchester.':
'Un guide régional pour affiner une recherche large autour du Grand Manchester.',
'Start a postcode search': 'Lancer une recherche de code postal',
'Commute property search': 'Recherche de propriété pour les déplacements domicile-travail',
'Commute property search': 'Recherche immobilière par temps de trajet',
'Search for places to live by commute time':
'Rechercher des lieux de résidence par temps de trajet',
'Commute property search - Find places to live by travel time':
'Recherche de propriété pour les déplacements domicile-travail  Trouver des endroits où vivre en fonction du temps de trajet',
'Recherche immobilière par temps de trajet  Trouver des endroits où vivre selon le temps de trajet',
'Filter postcodes by commute time, then compare price, schools, safety, broadband, road noise, parks and property data on one map.':
'Filtrez les codes postaux par temps de trajet, puis comparez les données sur les prix, les écoles, la sécurité, le haut débit, le bruit routier, les parcs et les propriétés sur une seule carte.',
'Filter postcodes by modelled car, cycling, walking, and public transport travel times, then layer on property price, schools, crime, broadband, noise, and local amenities.':
@ -255,7 +256,7 @@ const fr: Translations = {
'Why commute filters are combined with property data':
'Pourquoi les filtres de trajet domicile-travail sont combinés avec les données de propriété',
'Commute search is most useful when it removes impossible areas while still showing whether the remaining options are affordable and liveable.':
"La recherche de trajet est plus utile lorsqu'elle supprime les zones impossibles tout en indiquant si les options restantes sont abordables et vivable.",
"La recherche par temps de trajet est plus utile lorsqu'elle élimine les zones impossibles tout en indiquant si les options restantes sont abordables et agréables à vivre.",
'Can I compare car, cycling, walking, and public transport?':
'Puis-je comparer la voiture, le vélo, la marche et les transports publics ?',
'The product supports multiple travel modes where precomputed destination data is available.':
@ -285,22 +286,22 @@ const fr: Translations = {
'Compare family-friendly trade-offs across unfamiliar postcodes.':
'Comparez les compromis adaptés aux familles entre des codes postaux inconnus.',
'Use the map as a shortlist tool before checking admissions and catchments.':
'Utilisez la carte comme outil de présélection avant de vérifier les admissions et les bassins versants.',
'Utilisez la carte comme outil de présélection avant de vérifier les admissions et les secteurs scolaires.',
'Use school context without ignoring the home':
'Utiliser le contexte scolaire sans ignorer la maison',
'Start with property size, budget, and commute constraints, then layer in nearby school quality and local context. This prevents school-led searches from hiding affordability or daily-life problems.':
"Commencez par la taille de la propriété, le budget et les contraintes de déplacement, puis ajoutez la qualité des écoles à proximité et le contexte local. Cela empêche les recherches menées par l'école de cacher des problèmes d'abordabilité ou de la vie quotidienne.",
'Verify admissions before deciding': 'Vérifier les admissions avant de décider',
'School data can point to promising areas, but admissions rules and catchments can change. Confirm current arrangements with schools and local authorities.':
'Les données scolaires peuvent indiquer des domaines prometteurs, mais les règles dadmission et les bassins versants peuvent changer. Confirmez les arrangements actuels avec les écoles et les autorités locales.',
'Les données scolaires peuvent indiquer des zones prometteuses, mais les règles dadmission et les secteurs scolaires peuvent changer. Confirmez les arrangements actuels avec les écoles et les autorités locales.',
'School quality is one part of the shortlist':
"La qualité de l'école fait partie de la liste restreinte",
'Perfect Postcode helps you compare nearby school data with the other practical constraints that shape a family move: space, price, commute, parks, safety, and local services.':
'Perfect Postcode vous aide à comparer les données des écoles à proximité avec les autres contraintes pratiques qui façonnent un déménagement familial : espace, prix, déplacements domicile-travail, parcs, sécurité et services locaux.',
'Check catchments before making decisions':
'Vérifier les bassins versants avant de prendre des décisions',
'Vérifier les secteurs scolaires avant de prendre des décisions',
'Admissions rules and catchment boundaries can change. Use postcode-level school data to find promising areas, then verify current admissions details with the school or local authority.':
"Les règles d'admission et les limites des bassins versants peuvent changer. Utilisez les données scolaires au niveau du code postal pour trouver des zones prometteuses, puis vérifiez les détails actuels des admissions auprès de l'école ou des autorités locales.",
"Les règles d'admission et les limites des secteurs scolaires peuvent changer. Utilisez les données scolaires au niveau du code postal pour trouver des zones prometteuses, puis vérifiez les détails actuels des admissions auprès de l'école ou des autorités locales.",
'How to treat school filters': 'Comment traiter les filtres scolaires',
'Use school filters to narrow research, not to assume admission eligibility. Ratings, distance, admissions criteria, and school capacity should all be checked with current official sources.':
"Utilisez les filtres scolaires pour affiner la recherche, et non pour supposer léligibilité à ladmission. Les notes, la distance, les critères d'admission et la capacité de l'école doivent tous être vérifiés auprès des sources officielles actuelles.",
@ -308,16 +309,16 @@ const fr: Translations = {
'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.':
"Combinez les écoles avec les parcs, le bruit de la route, la criminalité, la taille de la propriété, les déplacements domicile-travail, le haut débit et le prix afin que la liste restreinte reflète l'ensemble du déménagement.",
'Does this show school catchment guarantees?':
'Cela montre-t-il des garanties de fréquentation scolaire ?',
'Cela garantit-il lappartenance à un secteur scolaire ?',
'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.':
"Non. Cela permet d'identifier les zones prometteuses, mais les recrutements et les admissions doivent être vérifiés auprès de l'école ou des autorités locales.",
"Non. Cela permet d'identifier les zones prometteuses, mais les secteurs scolaires et les admissions doivent être vérifiés auprès de l'école ou des autorités locales.",
'Can I combine school filters with parks and safety?':
'Puis-je combiner les filtres scolaires avec les parcs et la sécurité ?',
'Yes. School-aware search can be combined with crime, parks, commute, price, property size, and local services.':
"Oui. La recherche adaptée à l'école peut être combinée avec la criminalité, les parcs, les déplacements domicile-travail, le prix, la taille de la propriété et les services locaux.",
'Is Ofsted the only school signal?': 'Ofsted est-il le seul signal scolaire ?',
'No single score should decide a move. Use the map as a starting point, then review current school information in detail.':
"Aucun score ne devrait décider d'un coup. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur lécole.",
"Aucun score isolé ne devrait décider dun déménagement. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur lécole.",
'See where education, property, transport, and environment data comes from.':
"Découvrez d'où proviennent les données sur l'éducation, l'immobilier, les transports et l'environnement.",
'Explore school-aware searches': "Explorez les recherches adaptées à l'école",
@ -328,7 +329,7 @@ const fr: Translations = {
'Check postcode-level property prices, EPC data, crime, broadband, road noise, schools, council tax, amenities and travel-time context.':
"Vérifiez les prix de l'immobilier au niveau du code postal, les données EPC, la criminalité, le haut débit, le bruit de la route, les écoles, la taxe d'habitation, les commodités et le contexte du temps de trajet.",
'Review property prices, EPC context, crime, broadband, road noise, local amenities, schools, deprivation, council tax, and travel-time data from one postcode-first map.':
"Examinez les prix de l'immobilier, le contexte EPC, la criminalité, le haut débit, le bruit de la route, les commodités locales, les écoles, les privations, la taxe d'habitation et les données sur le temps de trajet à partir d'une seule carte indiquant le code postal.",
"Examinez les prix de l'immobilier, le contexte EPC, la criminalité, le haut débit, le bruit de la route, les commodités locales, les écoles, la défavorisation, la taxe d'habitation et les données sur le temps de trajet à partir d'une seule carte indiquant le code postal.",
'Check multiple local signals before visiting a street.':
'Vérifiez plusieurs signaux locaux avant de visiter une rue.',
'Use official and open datasets rather than reputation alone.':
@ -336,7 +337,7 @@ const fr: Translations = {
'Compare postcodes consistently across England.':
'Comparez les codes postaux de manière cohérente dans toute lAngleterre.',
'Check the street before spending a viewing slot':
"Vérifiez la rue avant de passer un créneau d'observation",
"Vérifiez la rue avant dy consacrer un créneau de visite",
'Use the postcode checker to review price history, local context, amenities, schools, and environment signals before you commit time to visiting.':
"Utilisez le vérificateur de code postal pour examiner l'historique des prix, le contexte local, les commodités, les écoles et les signaux environnementaux avant de consacrer du temps à votre visite.",
'Compare neighbouring postcodes': 'Comparez les codes postaux voisins',
@ -364,7 +365,7 @@ const fr: Translations = {
'Does the checker include exact property condition?':
'Le vérificateur inclut-il létat exact de la propriété ?',
'No. Property condition requires listing details, surveys, and direct inspection.':
'Létat de la propriété nécessite des détails dinscription, des enquêtes et une inspection directe.',
'Non. Létat dun bien nécessite les détails de lannonce, des expertises et une inspection directe.',
'Can I compare multiple postcodes?': 'Puis-je comparer plusieurs codes postaux ?',
'Yes. The map is designed for consistent comparison across postcodes.':
'Oui. La carte est conçue pour une comparaison cohérente entre les codes postaux.',
@ -407,7 +408,7 @@ const fr: Translations = {
'Search by travel time and then layer on property requirements.':
'Recherchez par temps de trajet, puis superposez les exigences de propriété.',
'Understand how to interpret filters and limitations.':
'Comprendre comment interpréter les filtres et les limitations.',
'Comprenez comment interpréter les filtres et les limites.',
'Compare Birmingham postcodes': 'Comparez les codes postaux de Birmingham',
'How to compare Manchester postcodes for a property search':
'Comment comparer les codes postaux de Manchester pour une recherche de propriété',
@ -479,7 +480,7 @@ const fr: Translations = {
'Compare Bristol postcodes': 'Comparez les codes postaux de Bristol',
'Trust and coverage': 'Confiance et couverture',
'Perfect Postcode data sources and coverage':
'Sources de données et couverture parfaites du code postal',
'Perfect Postcode — sources de données et couverture',
'Perfect Postcode data sources - Property, schools, commute and local context':
'Sources de données Perfect Postcode  Propriété, écoles, déplacements domicile-travail et contexte local',
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.':
@ -501,13 +502,13 @@ const fr: Translations = {
'Travel-time filters are designed for consistent area comparison. Route availability, disruption, parking, walking access, and timetable details should be verified before committing to an area.':
"Les filtres de temps de trajet sont conçus pour une comparaison cohérente des zones. La disponibilité des itinéraires, les perturbations, le stationnement, l'accès à pied et les détails des horaires doivent être vérifiés avant de s'engager dans une zone.",
'Why does coverage focus on England?':
'Pourquoi la couverture médiatique se concentre-t-elle sur lAngleterre ?',
'Pourquoi la couverture se concentre-t-elle sur lAngleterre ?',
'Several core property, education, and local-context datasets are jurisdiction-specific. England coverage keeps comparisons more consistent.':
'Plusieurs ensembles de données de base sur la propriété, léducation et le contexte local sont spécifiques à une juridiction. La couverture de lAngleterre rend les comparaisons plus cohérentes.',
'How should I handle stale or missing data?':
'Comment dois-je gérer les données obsolètes ou manquantes ?',
'Use the map as a shortlist tool. If a postcode matters, verify the latest details with current official sources and direct local checks.':
'Utilisez la carte comme outil de présélection. Si un code postal est important, vérifiez les derniers détails auprès des sources officielles actuelles et dirigez les contrôles locaux.',
'Utilisez la carte comme outil de présélection. Si un code postal est important, vérifiez les derniers détails auprès des sources officielles actuelles et effectuez des contrôles locaux directs.',
'How filters and comparisons should be interpreted.':
'Comment les filtres et les comparaisons doivent être interprétés.',
'Review postcode-level context before a viewing.':
@ -548,15 +549,15 @@ const fr: Translations = {
'Privacy and security for saved property searches':
'Confidentialité et sécurité pour les recherches de propriétés enregistrées',
'Perfect Postcode privacy and security - Saved searches and account data':
'Confidentialité et sécurité parfaites du code postal - Recherches enregistrées et données de compte',
'Perfect Postcode — confidentialité et sécurité : recherches enregistrées et données de compte',
'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.':
"Découvrez comment Perfect Postcode traite les recherches enregistrées, les données de compte et les flux de recherche de propriété en gardant à l'esprit la confidentialité et la sécurité.",
'Property research can reveal personal priorities, budgets, and locations. The product keeps public SEO pages separate from account-only areas and marks private dashboard/account routes as noindex.':
'La recherche immobilière peut révéler des priorités personnelles, des budgets et des emplacements. Le produit sépare les pages de référencement publiques des zones réservées aux comptes et marque les itinéraires de tableau de bord/compte privés comme non-index.',
'La recherche immobilière peut révéler des priorités personnelles, des budgets et des emplacements. Le produit sépare les pages de référencement publiques des zones réservées aux comptes et marque les itinéraires privés du tableau de bord/compte comme noindex.',
'Public pages and private areas are separated':
'Les pages publiques et les zones privées sont séparées',
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
"Les pages marketing, méthodologie, guide et support sont indexables. Le tableau de bord, le compte, les recherches enregistrées, les invitations et les itinéraires d'invitation sont marqués comme non indexés ou bloqués pour l'accès du robot, le cas échéant.",
"Les pages marketing, méthodologie, guide et support sont indexables. Le tableau de bord, le compte, les recherches enregistrées, les invitations et les itinéraires d'invitation sont marqués noindex ou bloqués à laccès des robots dindexation, le cas échéant.",
'Saved search data is account-scoped':
'Les données de recherche enregistrées sont limitées au compte',
'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
@ -572,7 +573,7 @@ const fr: Translations = {
'Can private dashboard URLs appear in search?':
'Les URL de tableaux de bord privés peuvent-elles apparaître dans la recherche ?',
'They shouldnt be indexed. The server marks private routes noindex and the sitemap only lists public pages.':
'Ils ne devraient pas être indexés. Le serveur marque les routes privées sans index et le plan du site ne répertorie que les pages publiques.',
'Ils ne devraient pas être indexés. Le serveur marque les routes privées en noindex et le plan du site ne répertorie que les pages publiques.',
'How to use public postcode data responsibly.':
'Comment utiliser les données publiques des codes postaux de manière responsable.',
'What data powers the public comparisons.':
@ -611,7 +612,7 @@ const fr: Translations = {
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
oneTimePayment: 'Paiement unique. Accès à vie.',
redirecting: 'Redirection...',
claimFreeAccess: 'Réclamer laccès gratuit',
claimFreeAccess: 'Obtenir laccès gratuit',
upgradeFor: 'Passer à la version complète pour {{price}}',
registerAndUpgrade: 'Sinscrire et passer à la version complète',
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
@ -662,7 +663,7 @@ const fr: Translations = {
oneTimeLifetime: 'Paiement unique, accès à vie.',
upgradeToFullMap: 'Passer à la carte complète',
chooseFilters:
'Cliquez sur Ajouter pour filtrer. Les petits boutons affichent les données ou colorent la carte.',
'Cliquez sur Ajouter pour filtrer. Les petits boutons affichent les détails des données ou colorent la carte.',
searchFeatures: 'Rechercher des critères...',
noMatchingFeatures: 'Aucun critère correspondant',
tryDifferentSearch: 'Essayez un autre terme de recherche',
@ -681,8 +682,12 @@ const fr: Translations = {
clearAll: 'Tout effacer',
clearAllTitle: 'Effacer tous les filtres ?',
clearAllSavePrompt: 'Souhaitez-vous sauvegarder vos filtres actuels avant de les effacer ?',
clearAllUpdatePrompt:
'Mettre à jour <strong>{{name}}</strong> avec vos filtres actuels avant deffacer ?',
saveAndClear: 'Sauvegarder et effacer',
updateAndClear: 'Mettre à jour et effacer',
clearWithoutSaving: 'Effacer sans sauvegarder',
clearWithoutUpdating: 'Effacer sans mettre à jour',
filtersOut: 'exclut {{value}}',
schoolType: 'Type décole',
schoolRating: 'Note de lécole',
@ -818,7 +823,7 @@ const fr: Translations = {
areaPane: {
areaStatistics: 'Statistiques de la zone',
areaOverview: 'Vue densemble',
statsFor: 'Statistiques pour toutes les propriétés de ce/cette {{type}}',
statsFor: 'Statistiques pour toutes les propriétés dans ce/cette {{type}}',
matchingFilters: ' correspondant à tous les filtres actifs',
statsBasis: 'Base des statistiques',
matchingFiltersOption: 'Filtres actifs',
@ -834,10 +839,13 @@ const fr: Translations = {
showAllStatsFallback:
'Passez à toutes les propriétés pour inspecter cette zone sans les filtres actifs.',
showAllStats: 'Afficher toutes les propriétés',
closestBlockingFilters: 'Filtres les plus proches qui excluent cette zone',
closestBlockingFilters: 'Modifications les plus proches pour inclure cette zone',
lowerMinTo: 'Abaisser le minimum à {{value}}',
raiseMaxTo: 'Augmenter le maximum à {{value}}',
allowCategory: 'Autoriser {{value}}',
missingFilterValue:
'Aucune valeur pour ce filtre ; supprimez-le',
noFilterDataShort: 'Aucune donnée',
travelTo: 'Trajet vers {{destination}}',
viewProperties: 'Voir {{count}} propriétés',
viewPropertiesShort: 'Voir les propriétés',
@ -903,17 +911,18 @@ const fr: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroEyebrow: 'Trouvez dabord où chercher',
heroTitle1: 'Arrêtez de chercher',
heroTitle2: 'aux mauvais endroits',
heroTitle3: 'Avant que les annonces ne resserrent votre recherche.',
heroSubtitle: 'Trouvez les codes postaux où budget, trajet et quotidien salignent.',
heroEyebrow: 'Pour les acheteurs qui se demandent « où chercher ? »',
heroTitle1: 'Trouvez les codes postaux',
heroTitle2: 'qui correspondent à votre vie',
heroTitle3: 'Pas seulement les quartiers que vous connaissez déjà.',
heroSubtitle:
'Des quartiers londoniens aux villes de banlieue et aux villes régionales, lAngleterre compte trop de lieux pour les rechercher un par un.',
heroDescription:
'Perfect Postcode filtre dabord chaque code postal, pour que vous ne couriez après des visites que dans les lieux qui fonctionnent.',
exploreTheMap: 'Montrez-moi où chercher',
seeTheDifference: 'Voir la démo',
productDemoLabel: 'Voir comment trouver où chercher dabord',
playProductDemo: 'Lire la démo « où chercher »',
'Définissez votre budget, trajet, écoles, sécurité, bruit, débit internet et style de vie. Perfect Postcode analyse les codes postaux dAngleterre et révèle les lieux qui correspondent vraiment, y compris ceux que vous nauriez jamais cherchés sur un portail immobilier.',
exploreTheMap: 'Trouver mes codes postaux',
seeTheDifference: 'Voir comment ça marche',
productDemoLabel: 'Démo produit Perfect Postcode',
playProductDemo: 'Lire la démo produit Perfect Postcode',
scrollToProductDemo: 'Faire défiler jusquà la démo produit',
showcaseHeader: 'Comment ça marche',
showcaseContext: 'Comment fonctionne Perfect Postcode',
@ -921,44 +930,45 @@ const fr: Translations = {
showcaseFeatureNoiseShort: 'Bruit',
showcaseFeatureSchoolsShort: 'Écoles',
showcaseFeatureTravelShort: 'Trajet',
showcaseGoodPrimariesNearby: '{{count}}+ écoles primaires Good ou Outstanding à proximité',
showcaseWithinRail: 'À moins de {{count}} min dune gare',
showcaseMatchingHomesLabel: 'Codes postaux correspondants',
showcaseMatchingHomes: '{{value}} codes postaux correspondants',
showcaseGoodPrimariesNearby: '{{count}}+ bonnes écoles primaires à proximité',
showcaseWithinRail: 'À moins de {{count}} min du train',
showcaseMatchingHomesLabel: 'Biens correspondants',
showcaseMatchingHomes: '{{value}} biens correspondants',
showcaseMedianPrice: 'médiane {{value}}',
showcaseJourneyRoutes: 'Itinéraires',
showcaseNearby: '{{value}} à proximité',
showcasePoliticalVoteShare: 'Répartition des voix',
showcaseLotsMore: 'Plus de données de quartier',
showcaseLotsMore: '...et bien plus',
showcaseMinutes: '{{count}} min',
showcaseSendShortlist: 'Envoyer la sélection',
showcaseDownloadXlsx: 'Télécharger le .xlsx',
showcaseTopThree: 'Top 3',
showcaseScoutBullet1: 'Vérifiez la rue avant de vous engager dans des alertes dannonces.',
showcaseScoutBullet1:
'Parcourez les rues avant que la recherche dannonces ne réduise vos options.',
showcaseScoutBullet2:
'Testez le trajet depuis une vraie porte dentrée, pas seulement depuis un nom darrondissement.',
showcaseScoutBullet3: 'Comparez les visites avec des preuves déjà en main.',
showcaseStep1Tab: 'Filtrer',
showcaseStep1Title: 'Définissez ce qui doit fonctionner',
showcaseStep1Title: 'Transformez des besoins vagues en recherche précise',
showcaseStep1Body:
'Ajoutez budget, trajet, écoles, sécurité, bruit et détails locaux. Regardez les mauvais codes postaux disparaître.',
'Définissez ce qui compte et voyez exactement combien de codes postaux inadaptés chaque exigence retire de votre recherche.',
showcaseStep1Chip1: 'Rues calmes',
showcaseStep1Chip2: 'Bonnes écoles primaires proches',
showcaseStep1Chip2: 'Écoles primaires bien notées',
showcaseStep1Chip3: 'Moins de £500k',
showcaseStep1VennCenter: 'Codes postaux qui cochent les trois',
showcaseStep2Tab: 'Associer',
showcaseStep2Title: 'Voyez les lieux qui restent',
showcaseStep2Tab: 'Comparer',
showcaseStep2Title: 'Laissez la carte révéler des lieux que vous nauriez pas tapés',
showcaseStep2Body:
'Cherchez par critères pratiques, pas par noms familiers. La carte montre les grappes de codes postaux à vérifier en premier.',
'Parcourez lAngleterre par adéquation au lieu de partir de noms de quartiers familiers. Des poches méconnues deviennent visibles avant que les portails dannonces ne réduisent votre horizon.',
showcaseStep2Region: 'Grand Londres',
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseStep2ClustersLabel: 'Grappes correspondantes',
showcaseStep3Tab: 'Inspecter',
showcaseStep3Title: 'Vérifiez les preuves',
showcaseStep3Title: 'Comprenez pourquoi un code postal correspond',
showcaseStep3Body:
'Ouvrez un code postal et voyez prix, trajet, écoles, criminalité, débit internet et compromis avant de vous déplacer.',
showcaseStep3HeaderArea: 'Code postal présélectionné',
showcaseStep3HeaderFit: 'Ce qui fonctionne',
'Ouvrez nimporte quelle zone correspondante et vérifiez prix, sécurité, écoles, débit internet et compromis dans un seul panneau avant dy passer un week-end.',
showcaseStep3HeaderArea: 'Votre code postal idéal',
showcaseStep3HeaderFit: 'Éléments sur le quartier',
showcaseStep3Stat1Label: 'Tendance des prix vendus',
showcaseStep3Stat2Label: 'Criminalité',
showcaseStep3Stat2Value: 'Sous la moyenne de larrondissement',
@ -968,34 +978,34 @@ const fr: Translations = {
showcaseStep3Stat5Label: 'Écoles primaires',
showcaseStep3Stat5Value: '3 « Excellent » à moins dun mile',
showcaseStep4Tab: 'Repérer',
showcaseStep4Title: 'Emmenez la sélection dans la rue',
showcaseStep4Title: 'Allez vérifier par vous-même',
showcaseStep4Body:
'Exportez les codes postaux à vérifier, testez le trajet, parcourez les rues et comparez les visites avec le contexte sauvegardé.',
'Emportez trois points de départ solides dans le monde réel. Parcourez les rues, testez le trajet et comparez les visites avec du contexte.',
showcaseStep4FileName: 'areas-to-scout.xlsx',
showcaseStep4ExportLabel: 'Exporter vers Excel',
showcaseStep4ColPostcode: 'Code postal',
showcaseStep4ColScore: 'Adéquation',
showcaseStep4ColScore: 'Ajust.',
showcaseStep4ColCommute: 'Trajet',
showcaseStep4ColPrice: 'Prix de vente médian',
showcaseStep4Conclusion: 'Exportez une sélection et commencez à vérifier les rues.',
statProperties: 'ventes HM Land Registry',
statFilters: 'façons de resserrer la carte',
showcaseStep4ColPrice: 'Prix médian',
showcaseStep4Conclusion: 'Vous pouvez commencer votre recherche ici.',
statProperties: 'ventes historiques',
statFilters: 'filtres combinables',
statEvery: 'Chaque',
statPostcodeInEngland: 'code postal actif en Angleterre',
ourPhilosophy: 'Arrêtez de commencer par les villes que vous connaissez déjà.',
statPostcodeInEngland: 'code postal dAngleterre',
ourPhilosophy: 'Commencez par ce qui compte, puis trouvez le bon code postal',
philosophyP1:
'La plupart des recherches commencent par un nom de lieu, puis espèrent que les bons biens apparaîtront. Cela évite la question plus difficile : quels lieux valent vraiment la peine dêtre recherchés ?',
'La plupart des sites immobiliers demandent où vous voulez vivre. À Londres, cest particulièrement difficile, mais le même problème existe partout en Angleterre : les acheteurs partent des quelques lieux quils connaissent, puis vérifient séparément trajets, écoles, criminalité, Street View, débit internet et prix vendus.',
philosophyP2:
'Perfect Postcode commence avant le site dannonces. Définissez ce quun lieu doit permettre, puis voyez les codes postaux qui méritent votre attention en premier.',
'Perfect Postcode inverse la recherche. Dites à la carte ce qui compte et elle affiche les codes postaux qui correspondent, avec les raisons pour lesquelles ils méritent dêtre étudiés. Les données dabord, puis allez tester lambiance.',
streetTitle: 'Tout change rue par rue',
streetIntro:
'Le bon côté dune gare, une route bruyante ou une zone de recrutement scolaire peuvent changer la recherche. Les noms de zones gomment tout cela.',
streetCard1Title: 'Échappez au piège des noms familiers',
'Les grands noms de quartiers cachent les détails importants : le côté de la gare, le bruit de la route, les écoles, le trajet exact et les vrais prix de vente.',
streetCard1Title: 'Trouvez les zones que vous auriez manquées',
streetCard1Body:
'Trouvez des correspondances au niveau du code postal hors des lieux déjà sur votre liste.',
streetCard2Title: 'Connaissez les compromis avant dy aller',
'Faites ressortir les codes postaux qui correspondent à vos critères, au lieu de dépendre seulement des noms connus ou des recommandations.',
streetCard2Title: 'Voyez les compromis avant les visites',
streetCard2Body:
'Vérifiez prix, trajet, bruit, écoles, sécurité, débit internet et commodités proches avant de réserver des visites.',
'Comparez prix, surface, trajet, sécurité, écoles, débit internet, bruit et énergie avant de passer vos week-ends à courir les visites.',
othersVs: 'Les autres vs',
checkMyPostcode: 'Portails dannonces',
areaGuides: 'Rapports de code postal',
@ -1005,11 +1015,11 @@ const fr: Translations = {
compAreaDataSub: '(criminalité, écoles, bruit, débit internet, services)',
compPropertyData: 'Historique par propriété',
compPropertyDataSub: '(prix vendus, DPE, surface, valeur estimée)',
compFilters: 'Budget, trajet, écoles, sécurité et données locales ensemble',
compFiltersSub: '(budget + trajet + écoles + sécurité + contexte local)',
ctaTitle: 'Trouvez où chercher avant de réserver des visites.',
compFilters: '56 filtres qui fonctionnent ensemble',
compFiltersSub: '(pas un code postal ou une annonce à la fois)',
ctaTitle: 'Arrêtez de deviner où acheter.',
ctaDescription:
'Construisez une sélection de codes postaux à partir de ce qui compte, puis vérifiez les rues sur place.',
'Construisez une sélection de codes postaux adaptés à votre vraie vie, puis allez les tester sur place.',
},
// ── Pricing Page ───────────────────────────────────
@ -1082,10 +1092,10 @@ const fr: Translations = {
dsNsplOrigin: 'ONS / ArcGIS',
dsNsplUse:
'Associe les codes postaux aux coordonnées et aux codes de zones statistiques, utilisé pour relier tous les jeux de données au niveau de la zone aux propriétés individuelles.',
dsIodName: 'Indices anglais de défaveur 2025',
dsIodName: 'Indices de défavorisation en Angleterre 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse:
'Percentiles nationaux de défaveur couvrant le revenu, lemploi, léducation, la santé, la criminalité et le cadre de vie pour chaque quartier dAngleterre.',
'Percentiles nationaux de défavorisation couvrant le revenu, lemploi, léducation, la santé, la criminalité et le cadre de vie pour chaque quartier dAngleterre.',
dsEthnicityName: 'Population par ethnie (recensement 2021)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
@ -1246,13 +1256,13 @@ const fr: Translations = {
// FAQ items — Tips and Tricks
faqTips1Q: 'Comment prévisualiser un filtre sur la carte ?',
faqTips1A:
'Cliquez sur licône en forme dœil à côté dun filtre ou dune donnée pour colorer la carte selon cet élément. Vos filtres actifs restent en place, ce qui permet de comparer rapidement une chose comme le prix, le trajet, les écoles, la criminalité ou le bruit sans modifier la sélection.',
'Cliquez sur Colorer à côté dun filtre ou dun critère pour colorer la carte selon cet élément. Vos filtres actifs restent en place, ce qui permet de comparer rapidement une chose comme le prix, le trajet, les écoles, la criminalité ou le bruit sans modifier votre sélection.',
faqTips2Q: 'Comment comprendre ce que signifie un filtre ?',
faqTips2A:
'Cliquez sur le bouton dinformation i à côté dun filtre ou dune donnée pour voir une courte explication de ce que cela signifie et comment le lire. Certaines parties de la carte, comme les cartes de temps de trajet, ont aussi leur propre bouton dinformation.',
'Cliquez sur À propos à côté dun filtre ou dun critère pour ouvrir une courte explication de ce quil signifie et comment le lire. Certaines zones de la carte, comme les cartes de temps de trajet, disposent aussi de leur propre explication des données.',
faqTips3Q: 'Comment actualiser les couleurs de la carte ?',
faqTips3A:
'Lorsquune prévisualisation avec lœil colore la carte, utilisez Réinitialiser léchelle de couleur dans la légende pour actualiser les couleurs des résultats affichés. Cest utile après un déplacement, un zoom ou une modification des filtres.',
'Lorsquun critère colore la carte, utilisez Réinitialiser léchelle de couleur dans la légende de la carte pour actualiser les couleurs des résultats affichés. Cest utile après un déplacement, un zoom ou une modification des filtres.',
},
// ── Account Page ───────────────────────────────────
@ -1281,11 +1291,13 @@ const fr: Translations = {
deleteSearch: 'Supprimer la recherche',
deleteSearchConfirm:
'Êtes-vous sûr de vouloir supprimer cette recherche enregistrée ? Cette action est irréversible.',
isBeingUpdated: 'Mise à jour de <strong>{{name}}</strong>',
updating: 'Mise à jour...',
},
// ── Invites Page ───────────────────────────────────
invitesPage: {
inviteLinksLicensed: 'Les liens dinvitation sont disponibles pour les utilisateurs licenciés.',
inviteLinksLicensed: 'Les liens dinvitation sont disponibles pour les utilisateurs sous licence.',
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
generateFreeInvite: 'Générer un lien dinvitation gratuit',
@ -1321,8 +1333,8 @@ const fr: Translations = {
fullAccessGranted: 'Vous avez désormais un accès complet à Perfect Postcode.',
activating: 'Activation...',
activateLicense: 'Activer la licence',
claimDiscount: 'Réclamer la réduction',
registerToClaim: 'Sinscrire pour réclamer',
claimDiscount: 'Obtenir la réduction',
registerToClaim: 'Sinscrire pour lobtenir',
youAlreadyHaveLicense: 'Vous avez déjà une licence',
accountHasFullAccess: 'Votre compte dispose déjà dun accès complet.',
failedToValidate: 'Échec de la validation du lien dinvitation',
@ -1352,7 +1364,7 @@ const fr: Translations = {
tutorial: {
step1Title: 'Dites à la carte ce qui compte',
step1Content:
'Définissez votre budget, limite de trajet, qualité des écoles, seuil de criminalité, tolérance au bruit, besoins en débit internet ou tout ce qui compte pour vous. Seules les zones correspondantes restent éclairées. Utilisez licône œil pour colorer par nimporte quel critère.',
'Définissez votre budget, limite de trajet, qualité des écoles, seuil de criminalité, tolérance au bruit, besoins en débit internet ou tout ce qui compte pour vous. Seules les zones correspondantes restent éclairées. Utilisez Colorer pour ombrer la carte selon nimporte quel critère.',
step2Title: 'Ou décrivez simplement',
step2Content:
'Tapez ce que vous voulez en langage courant, par exemple « quartier calme près de bonnes écoles sous £400k », et nous configurerons les filtres pour vous.',
@ -1379,6 +1391,7 @@ const fr: Translations = {
'Property prices': 'Prix immobiliers',
Transport: 'Transports',
Education: 'Éducation',
'Defining characteristics': 'Caractéristiques déterminantes',
'Area development': 'Développement du quartier',
Crime: 'Criminalité',
Neighbours: 'Voisins',

View file

@ -3,6 +3,7 @@ import type { Translations } from './en';
const hi: Translations = {
common: {
save: 'सहेजें',
update: 'अपडेट करें',
cancel: 'रद्द करें',
close: 'बंद करें',
delete: 'हटाएं',
@ -84,7 +85,7 @@ const hi: Translations = {
'हर पेज असली शॉर्टलिस्टिंग काम के लिए बना है: असंभव जगहें हटाना, बचे हुए पोस्टकोड की तुलना करना और अगली जांच तय करना.',
howToUseIt: 'इसे कैसे इस्तेमाल करें',
howToUseItDesc:
'लिस्टिंग पोर्टल खोलने या viewing बुक करने से पहले पेज को उपयोगी बनाने के लिए ये वर्कफ़्लो इस्तेमाल करें.',
'लिस्टिंग पोर्टल खोलने या मकान देखने की बुकिंग करने से पहले पेज को उपयोगी बनाने के लिए ये वर्कफ़्लो इस्तेमाल करें.',
methodAndLimitations: 'तरीका और सीमाएं',
methodAndLimitationsDesc:
'डेटा तुलना और शॉर्टलिस्टिंग के लिए है. महत्वपूर्ण निर्णयों के लिए अब भी ताजा लिस्टिंग, पेशेवर जांच और स्थानीय सत्यापन जरूरी है.',
@ -649,8 +650,12 @@ const hi: Translations = {
clearAll: 'सभी साफ करें',
clearAllTitle: 'सभी फिल्टर साफ करें?',
clearAllSavePrompt: 'क्या साफ करने से पहले आप अपने मौजूदा फिल्टर सहेजना चाहेंगे?',
clearAllUpdatePrompt:
'साफ करने से पहले <strong>{{name}}</strong> को अपने मौजूदा फिल्टर के साथ अपडेट करें?',
saveAndClear: 'सहेजें और साफ करें',
updateAndClear: 'अपडेट करें और साफ करें',
clearWithoutSaving: 'बिना सहेजे साफ करें',
clearWithoutUpdating: 'बिना अपडेट किए साफ करें',
filtersOut: '{{value}} को फिल्टर करता है',
schoolType: 'स्कूल प्रकार',
schoolRating: 'स्कूल रेटिंग',
@ -758,7 +763,7 @@ const hi: Translations = {
estValue: 'अनु. मूल्य:',
type: 'प्रकार:',
builtForm: 'निर्माण रूप:',
tenure: 'टेन्योर:',
tenure: 'कार्यकाल:',
floorArea: 'फर्श क्षेत्र:',
rooms: 'कमरे:',
built: 'निर्माण:',
@ -771,7 +776,7 @@ const hi: Translations = {
searchPlaceholder: 'पते या पोस्टकोड से खोजें...',
propertyData: 'संपत्ति डेटा',
propertyDataDesc:
'कीमतें HM Land Registry से आती हैं (खरीदारों ने वास्तव में क्या भुगतान किया). फर्श क्षेत्र, ऊर्जा रेटिंग, निर्माण वर्ष और टेन्योर आधिकारिक EPC सर्वेक्षणों से आते हैं. दोनों स्रोत हर पोस्टकोड के अंदर पते से मिलाए जाते हैं.',
'कीमतें HM Land Registry से आती हैं (खरीदारों ने वास्तव में क्या भुगतान किया). फर्श क्षेत्र, ऊर्जा रेटिंग, निर्माण वर्ष और कार्यकाल आधिकारिक EPC सर्वेक्षणों से आते हैं. दोनों स्रोत हर पोस्टकोड के अंदर पते से मिलाए जाते हैं.',
},
areaPane: {
@ -792,10 +797,12 @@ const hi: Translations = {
showAllStatsFallback:
'सक्रिय फिल्टर के बिना इस क्षेत्र को देखने के लिए सभी संपत्तियों पर जाएं.',
showAllStats: 'सभी संपत्तियां दिखाएं',
closestBlockingFilters: 'इस क्षेत्र को बाहर करने वाले निकटतम फिल्टर',
closestBlockingFilters: 'इस क्षेत्र को शामिल करने के निकटतम बदलाव',
lowerMinTo: 'न्यूनतम को {{value}} तक घटाएं',
raiseMaxTo: 'अधिकतम को {{value}} तक बढ़ाएं',
allowCategory: '{{value}} की अनुमति दें',
missingFilterValue: 'इस फिल्टर के लिए कोई मान नहीं है; इसे हटाएं',
noFilterDataShort: 'कोई डेटा नहीं',
travelTo: '{{destination}} तक यात्रा',
viewProperties: '{{count}} संपत्तियां देखें',
viewPropertiesShort: 'संपत्तियां देखें',
@ -834,7 +841,7 @@ const hi: Translations = {
},
externalSearch: {
searchOn: '{{radius}} पर खोजें',
searchOn: '{{radius}} को इन पर खोजें:',
exact: 'सटीक',
outcodeNotRecognised: 'आउटकोड पहचाना नहीं गया',
},
@ -854,17 +861,18 @@ const hi: Translations = {
},
home: {
heroEyebrow: 'पहले पता करें कि कहां देखना है',
heroTitle1: 'गलत जगहों पर',
heroTitle2: 'खोजना बंद करें',
heroTitle3: 'इससे पहले कि लिस्टिंग आपकी खोज सीमित कर दें.',
heroSubtitle: 'ऐसे पोस्टकोड खोजें जहां आपका बजट, आवागमन और रोजमर्रा की जिंदगी मेल खाते हों.',
heroEyebrow: 'उन खरीदारों के लिए जो पूछते हैं “मुझे देखना कहां शुरू करना चाहिए?”',
heroTitle1: 'वे पोस्टकोड खोजें जो',
heroTitle2: 'आपकी जिंदगी से मेल खाते हैं',
heroTitle3: 'सिर्फ वे क्षेत्र नहीं जिन्हें आप पहले से जानते हैं.',
heroSubtitle:
'लंदन बरो से लेकर कम्यूटर टाउन और क्षेत्रीय शहरों तक, इंग्लैंड में एक-एक जगह रिसर्च करने के लिए बहुत अधिक स्थान हैं.',
heroDescription:
'Perfect Postcode पहले हर पोस्टकोड को फिल्टर करता है, ताकि आप सिर्फ उन्हीं जगहों पर मकान देखने जाएं जो सच में काम आती हैं.',
exploreTheMap: 'मुझे दिखाएं कहां देखना है',
seeTheDifference: 'डेमो देखें',
productDemoLabel: 'देखें कि पहले कहां देखना है कैसे पता करें',
playProductDemo: '“कहां देखना है” डेमो चलाएं',
'अपना बजट, आवागमन, स्कूल, सुरक्षा, शोर, ब्रॉडबैंड और जीवनशैली की जरूरतें सेट करें. Perfect Postcode इंग्लैंड के पोस्टकोड स्कैन करता है और वे जगहें दिखाता है जो सच में मेल खाती हैं, उन क्षेत्रों सहित जिन्हें आप किसी प्रॉपर्टी पोर्टल में कभी नहीं खोजते.',
exploreTheMap: 'मेरे मेल खाते पोस्टकोड खोजें',
seeTheDifference: 'देखें यह कैसे काम करता है',
productDemoLabel: 'Perfect Postcode उत्पाद डेमो',
playProductDemo: 'Perfect Postcode उत्पाद डेमो चलाएं',
scrollToProductDemo: 'उत्पाद डेमो तक स्क्रोल करें',
showcaseHeader: 'यह कैसे काम करता है',
showcaseContext: 'Perfect Postcode कैसे काम करता है',
@ -872,43 +880,43 @@ const hi: Translations = {
showcaseFeatureNoiseShort: 'शोर',
showcaseFeatureSchoolsShort: 'स्कूल',
showcaseFeatureTravelShort: 'यात्रा',
showcaseGoodPrimariesNearby: '{{count}}+ अच्छे या उत्कृष्ट प्राइमरी स्कूल पास में',
showcaseWithinRail: 'स्टेशन से {{count}} मिनट के भीतर',
showcaseMatchingHomesLabel: 'मेल खाते पोस्टकोड',
showcaseMatchingHomes: '{{value}} मेल खाते पोस्टकोड',
showcaseGoodPrimariesNearby: '{{count}}+ अच्छे प्राइमरी स्कूल पास में',
showcaseWithinRail: 'रेल से {{count}} मिनट के भीतर',
showcaseMatchingHomesLabel: 'मेल खाते घर',
showcaseMatchingHomes: '{{value}} मेल खाते घर',
showcaseMedianPrice: '{{value}} मीडियन',
showcaseJourneyRoutes: 'यात्रा मार्ग',
showcaseNearby: '{{value}} पास में',
showcasePoliticalVoteShare: 'राजनीतिक वोट हिस्सेदारी',
showcaseLotsMore: 'पड़ोस का और डेटा',
showcaseLotsMore: '...और भी बहुत कुछ',
showcaseMinutes: '{{count}} मिनट',
showcaseSendShortlist: 'शॉर्टलिस्ट भेजें',
showcaseDownloadXlsx: '.xlsx डाउनलोड करें',
showcaseTopThree: 'शीर्ष 3',
showcaseScoutBullet1: 'लिस्टिंग अलर्ट लगाने से पहले सड़क जांचें.',
showcaseScoutBullet1: 'लिस्टिंग खोज आपके विकल्प घटाए, उससे पहले सड़कें चलकर देखें.',
showcaseScoutBullet2: 'आवागमन किसी असली दरवाजे से जांचें, सिर्फ बरो नाम से नहीं.',
showcaseScoutBullet3: 'पहले से सहेजे गए प्रमाण के साथ मकान देखने के विकल्पों की तुलना करें.',
showcaseScoutBullet3: 'पहले से मौजूद प्रमाण के साथ मकान देखने की तुलना करें.',
showcaseStep1Tab: 'फिल्टर',
showcaseStep1Title: 'सेट करें कि क्या काम करना जरूरी है',
showcaseStep1Title: 'अस्पष्ट जरूरतों को सटीक खोज में बदलें',
showcaseStep1Body:
'बजट, आवागमन, स्कूल, सुरक्षा, शोर और स्थानीय विवरण जोड़ें. गलत पोस्टकोड को बाहर होते देखें.',
'जो मायने रखता है उसे सेट करें और देखें कि हर जरूरत कितने गलत-फिट पोस्टकोड को आपकी खोज से बाहर रखती है.',
showcaseStep1Chip1: 'शांत सड़कें',
showcaseStep1Chip2: 'पास में अच्छे प्राइमरी',
showcaseStep1Chip2: 'शीर्ष-रेटेड प्राइमरी',
showcaseStep1Chip3: '£500,000 से कम',
showcaseStep1VennCenter: 'तीनों शर्तों को पूरा करने वाले पोस्टकोड',
showcaseStep2Tab: 'मिलान',
showcaseStep2Title: 'बची हुई जगहें देखें',
showcaseStep2Title: 'मानचित्र को वे जगहें दिखाने दें जिन्हें आप टाइप नहीं करते',
showcaseStep2Body:
'परिचित नामों से नहीं, व्यावहारिक जांचों से खोजें. मानचित्र पहले जांचने लायक पोस्टकोड क्लस्टर दिखाता है.',
'परिचित इलाकों के नामों से शुरू करने के बजाय अपनी जरूरतों से मेल के आधार पर इंग्लैंड देखें. प्रॉपर्टी पोर्टल आपकी सोच सीमित करें, उससे पहले छिपे हुए अच्छे इलाके सामने आ जाते हैं.',
showcaseStep2Region: 'ग्रेटर लंदन',
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseStep2ClustersLabel: 'मेल खाते क्लस्टर',
showcaseStep3Tab: 'जांचें',
showcaseStep3Title: 'प्रमाण जांचें',
showcaseStep3Title: 'जांचें कि कोई पोस्टकोड क्यों चुना गया',
showcaseStep3Body:
'कोई पोस्टकोड खोलें और मकान देखने से पहले कीमत, आवागमन, स्कूल, अपराध, ब्रॉडबैंड और समझौते देखें.',
showcaseStep3HeaderArea: 'शॉर्टलिस्ट किया गया पोस्टकोड',
showcaseStep3HeaderFit: 'क्या काम करता है',
'किसी भी मेल खाते क्षेत्र को खोलें और वहां सप्ताहांत बिताने से पहले कीमतें, सुरक्षा, स्कूल, ब्रॉडबैंड और समझौते एक ही पैनल में जांचें.',
showcaseStep3HeaderArea: 'आपका परफेक्ट पोस्टकोड',
showcaseStep3HeaderFit: 'पड़ोस का प्रमाण',
showcaseStep3Stat1Label: 'बेची गई कीमत का रुझान',
showcaseStep3Stat2Label: 'अपराध दर',
showcaseStep3Stat2Value: 'बरो औसत से कम',
@ -918,33 +926,34 @@ const hi: Translations = {
showcaseStep3Stat5Label: 'प्राइमरी स्कूल',
showcaseStep3Stat5Value: '1 मील के अंदर 3 उत्कृष्ट',
showcaseStep4Tab: 'स्काउट',
showcaseStep4Title: 'शॉर्टलिस्ट को सड़कों तक ले जाएं',
showcaseStep4Title: 'खुद जाकर देखें',
showcaseStep4Body:
'जांचने लायक पोस्टकोड निर्यात करें, आवागमन आजमाएं, सड़कें चलकर देखें और सहेजे गए संदर्भ के साथ मकान देखने के विकल्पों की तुलना करें.',
'तीन ठोस शुरुआती बिंदुओं को वास्तविक दुनिया में ले जाएं. सड़कें चलकर देखें, आवागमन आजमाएं और संदर्भ के साथ मकानों की देखने-समझने की यात्राओं की तुलना करें.',
showcaseStep4FileName: 'areas-to-scout.xlsx',
showcaseStep4ExportLabel: 'Excel में निर्यात करें',
showcaseStep4ColPostcode: 'पोस्टकोड',
showcaseStep4ColScore: 'फिट',
showcaseStep4ColCommute: 'आवागमन',
showcaseStep4ColPrice: 'मीडियन बिक्री मूल्य',
showcaseStep4Conclusion: 'शॉर्टलिस्ट निर्यात करें और सड़कें जांचना शुरू करें.',
statProperties: 'HM Land Registry बिक्री',
statFilters: 'मानचित्र को संकरा करने के तरीके',
showcaseStep4ColPrice: 'मीडियन बिक्री',
showcaseStep4Conclusion: 'आप अपनी यात्रा यहां से शुरू कर सकते हैं.',
statProperties: 'ऐतिहासिक बिक्री',
statFilters: 'जोड़े जा सकने वाले फिल्टर',
statEvery: 'हर',
statPostcodeInEngland: 'इंग्लैंड का सक्रिय पोस्टकोड',
ourPhilosophy: 'उन कस्बों से शुरू करना बंद करें जिन्हें आप पहले से जानते हैं.',
statPostcodeInEngland: 'इंग्लैंड का पोस्टकोड',
ourPhilosophy: 'जो मायने रखता है उससे शुरू करें, फिर सही पोस्टकोड खोजें',
philosophyP1:
'अधिकांश खोजें किसी जगह के नाम से शुरू होती हैं, फिर उम्मीद करती हैं कि सही घर मिल जाएंगे. इससे कठिन सवाल छूट जाता है: कौन सी जगहें सच में खोजने लायक हैं?',
'अधिकांश संपत्ति साइटें पूछती हैं कि आप कहां रहना चाहते हैं. लंदन में यह बहुत कठिन है, लेकिन यही समस्या पूरे इंग्लैंड में आती है: खरीदार उन कुछ जगहों में से चुनते हैं जिन्हें वे जानते हैं, फिर आवागमन टूल, Ofsted, पुलिस डेटा, Street View, ब्रॉडबैंड जांच और बेचे गए दामों को अलग-अलग टैब में मिलाते हैं.',
philosophyP2:
'Perfect Postcode लिस्टिंग साइट से पहले शुरू होता है. सेट करें कि किसी जगह को क्या समर्थन देना चाहिए, फिर वे पोस्टकोड देखें जो पहले आपका ध्यान मांगते हैं.',
'Perfect Postcode खोज को उलट देता है. मानचित्र को बताएं कि क्या मायने रखता है और यह वे पोस्टकोड दिखाता है जो योग्य हैं, साथ में प्रमाण भी कि वे देखने लायक क्यों हैं. पहले डेटा, फिर माहौल खुद परखें.',
streetTitle: 'जगहें सड़क-दर-सड़क बदलती हैं',
streetIntro:
'स्टेशन का सही तरफ होना, शोर वाली सड़क या एक स्कूल कैचमेंट भी खोज बदल सकता है. क्षेत्र के नाम यह सब समतल कर देते हैं.',
streetCard1Title: 'परिचित नामों के जाल से बाहर निकलें',
streetCard1Body: 'अपनी सूची में पहले से मौजूद जगहों से बाहर पोस्टकोड-स्तर के मेल खोजें.',
streetCard2Title: 'जाने से पहले समझौते जानें',
'बड़े क्षेत्र नाम वे विवरण छिपा देते हैं जो मायने रखते हैं: स्टेशन किस तरफ है, सड़क का शोर, स्कूल मिश्रण, सटीक आवागमन और समान घर वास्तव में किस कीमत पर बिके.',
streetCard1Title: 'वे क्षेत्र खोजें जो आपसे छूट सकते थे',
streetCard1Body:
'परिचित नामों, दोस्तों की सिफारिशों या “उभरते इलाके” की चर्चा पर निर्भर रहने के बजाय अपनी जरूरतों से मेल खाते पोस्टकोड सामने लाएं.',
streetCard2Title: 'मकान देखने से पहले समझौते समझें',
streetCard2Body:
'मकान देखने की बुकिंग से पहले कीमत, आवागमन, शोर, स्कूल, सुरक्षा, ब्रॉडबैंड और पास की सुविधाएं जांचें.',
'सप्ताहांत मकान देखने में बिताने से पहले कीमत, जगह, आवागमन, सुरक्षा, स्कूल, ब्रॉडबैंड, शोर और ऊर्जा रेटिंग की तुलना करें.',
othersVs: 'दूसरे बनाम',
checkMyPostcode: 'प्रॉपर्टी पोर्टल',
areaGuides: 'पोस्टकोड रिपोर्ट',
@ -954,11 +963,11 @@ const hi: Translations = {
compAreaDataSub: '(अपराध, स्कूल, शोर, ब्रॉडबैंड, सुविधाएं)',
compPropertyData: 'संपत्ति-स्तर इतिहास',
compPropertyDataSub: '(बेची कीमतें, EPC, फर्श क्षेत्र, अनुमानित मूल्य)',
compFilters: 'बजट, आवागमन, स्कूल, सुरक्षा और स्थानीय डेटा साथ में',
compFiltersSub: '(बजट + आवागमन + स्कूल + सुरक्षा + स्थानीय संदर्भ)',
ctaTitle: 'मकान देखने की बुकिंग से पहले पता करें कि कहां देखना है.',
compFilters: '56 फिल्टर साथ काम करते हुए',
compFiltersSub: '(एक समय में केवल एक पोस्टकोड या एक लिस्टिंग नहीं)',
ctaTitle: 'कहां खरीदना है, इसका अनुमान लगाना बंद करें.',
ctaDescription:
'जो बातें मायने रखती हैं उनसे पोस्टकोड शॉर्टलिस्ट बनाएं, फिर सड़कों को खुद जांचें.',
'उन पोस्टकोड की शॉर्टलिस्ट बनाएं जो आपकी वास्तविक जिंदगी से मेल खाते हैं, फिर उन्हें खुद जांचें.',
},
pricingPage: {
@ -1152,7 +1161,7 @@ const hi: Translations = {
'क्षेत्र और संभावित मूल्य जांचने के लिए Perfect Postcode उपयोग करें, फिर प्रस्ताव देने से पहले लिस्टिंग विवरण पुष्टि करें. मालिकाना प्रकार, लीज विवरण, सेवा शुल्क, योजना इतिहास, बाढ़ जोखिम, कानूनी मुद्दे, बंधक संबंधी शर्तें और सर्वेक्षण परिणाम भी जांचें.',
faqPrivacy1Q: 'क्या आप मेरे बारे में व्यक्तिगत डेटा संग्रहीत करते हैं?',
faqPrivacy1A:
'संपत्ति और पड़ोस जानकारी में आपके व्यक्तिगत विवरण नहीं होते. अगर आप खाता बनाते हैं, तो हम सेवा चलाने के लिए जरूरी चीजें रखते हैं, जैसे ईमेल पता, एक्सेस स्थिति, न्यूज़लेटर चयन, सहेजी गई खोजें, सहेजी गई संपत्तियां और Stripe द्वारा संभाले गए भुगतान. खाते का डेटा UK गोपनीयता कानून के तहत संभाला जाता है.',
'संपत्ति और पड़ोस जानकारी में आपके व्यक्तिगत विवरण नहीं होते. अगर आप खाता बनाते हैं, तो हम सेवा चलाने के लिए जरूरी चीजें रखते हैं, जैसे ईमेल पता, एक्सेस स्थिति, न्यूज़लेटर चयन, सहेजी गई खोजें, साझा लिंक और Stripe द्वारा संभाले गए भुगतान रिकॉर्ड. खाते का डेटा यूके गोपनीयता कानून के तहत संभाला जाता है.',
faqWhy1Q: 'यह क्या दिखाता है जो लिस्टिंग पोर्टल आमतौर पर नहीं दिखाते?',
faqWhy1A:
'लिस्टिंग साइटें आज बिक रहे घरों से शुरू करती हैं. Perfect Postcode उन जगहों से शुरू करता है जो आपके जीवन और बजट से मेल खाती हैं, बेची कीमतों, जगह, आवागमन, स्कूल, अपराध, शोर, इंटरनेट, ऊर्जा रेटिंग, मालिकाना प्रकार और सुविधाओं के साथ, लिस्टिंग खोलने से पहले.',
@ -1206,6 +1215,8 @@ const hi: Translations = {
deleteSearch: 'खोज हटाएं',
deleteSearchConfirm:
'क्या आप वाकई यह सहेजी गई खोज हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
isBeingUpdated: '<strong>{{name}}</strong> अपडेट हो रहा है',
updating: 'अपडेट हो रहा है...',
},
invitesPage: {
@ -1295,6 +1306,7 @@ const hi: Translations = {
'Property prices': 'संपत्ति कीमतें',
Transport: 'परिवहन',
Education: 'शिक्षा',
'Defining characteristics': 'मुख्य विशेषताएं',
'Area development': 'क्षेत्र विकास',
Crime: 'अपराध',
Neighbours: 'पड़ोसी',

View file

@ -4,6 +4,7 @@ const hu: Translations = {
// ── Common ──────────────────────────────────────────
common: {
save: 'Mentés',
update: 'Frissítés',
cancel: 'Mégse',
close: 'Bezárás',
delete: 'Törlés',
@ -34,7 +35,7 @@ const hu: Translations = {
clickForDetails: 'Kattints a részletekhez',
property: 'ingatlan',
propertiesPlural: 'ingatlanok',
places: 'hely',
places: 'helyek',
noData: 'Nincs adat',
allLow: 'Mind alacsony',
connectingToServer: 'Kapcsolódás a szerverhez...',
@ -44,7 +45,7 @@ const hu: Translations = {
// ── Header / Nav ───────────────────────────────────
header: {
appName: 'Perfect Postcode',
dashboard: 'Térkép',
dashboard: 'Vezérlőpult',
learn: 'Tudnivalók',
pricing: 'Árak',
inviteFriends: 'Barátok meghívása',
@ -107,9 +108,9 @@ const hu: Translations = {
'Property price map for England - Compare postcodes before viewing':
'Ingatlan ártérkép Angliában - Hasonlítsa össze az irányítószámokat a megtekintés előtt',
'Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.':
'Hasonlítsa össze az eladási árakat, a becsült aktuális értéket, a négyzetméterenkénti árat és a helyi kontextust az angol irányítószámok között, mielőtt keresni kezdene az adatok között.',
'Hasonlítsa össze az eladási árakat, a becsült aktuális értéket, a négyzetméterenkénti árat és a helyi kontextust az angliai irányítószámok között, mielőtt hirdetéseket keresne.',
'Perfect Postcode maps sold prices, estimated current value, price per square metre, property type, floor area, tenure, and local context so buyers can find realistic search areas before opening listing portals.':
'A Perfect Postcode feltérképezi az eladási árakat, a becsült jelenlegi értéket, a négyzetméterenkénti árat, az ingatlan típusát, az alapterületet, a tulajdonjogot és a helyi környezetet, így a vásárlók reális keresési területeket találhatnak, mielőtt megnyitnák a listát tartalmazó portálokat.',
'A Perfect Postcode feltérképezi az eladási árakat, a becsült jelenlegi értéket, a négyzetméterenkénti árat, az ingatlan típusát, az alapterületet, a tulajdonjogot és a helyi környezetet, így a vásárlók reális keresési területeket találhatnak, mielőtt megnyitnák a hirdetési portálokat.',
'Screen historical sale prices and current-value estimates by postcode.':
'A korábbi eladási árak és a becsült aktuális érték megjelenítése irányítószám szerint.',
'Compare value with commute, schools, broadband, crime, noise, and amenities.':
@ -121,24 +122,24 @@ const hu: Translations = {
'Start with a maximum price and property type, then colour the map by price per square metre or estimated current price. This helps reveal areas where similar homes have historically traded within reach, even when there are no live listings today.':
'Kezdje a maximális árral és az ingatlantípussal, majd színezze ki a térképet négyzetméterár vagy becsült aktuális ár alapján. Ez segít feltárni azokat a területeket, ahol korábban hasonló otthonokkal kereskedtek elérhető közelségben, még akkor is, ha ma még nincsenek élő listák.',
'Filter by last known sale price, estimated current value, property type, tenure, and floor area.':
'Szűrés az utolsó ismert eladási ár, a becsült jelenlegi érték, az ingatlan típusa, birtoklása és alapterülete alapján.',
'Szűrés az utolsó ismert eladási ár, a becsült jelenlegi érték, az ingatlan típusa, tulajdonformája és alapterülete alapján.',
'Compare nearby postcodes using the same criteria instead of relying on area reputation.':
'Hasonlítsa össze a közeli irányítószámokat ugyanazokkal a kritériumokkal ahelyett, hogy a terület hírnevére hagyatkozna.',
'Use the results as a shortlist for listing alerts, local research, and viewings.':
'Az eredményeket rövid listaként használja a riasztások listázásához, a helyi kutatásokhoz és a megtekintésekhez.',
'Az eredményeket szűkített listaként használja hirdetési értesítésekhez, helyi kutatáshoz és megtekintésekhez.',
'Separate cheap from good value': 'Különítse el az olcsót a jó értéktől',
'A lower price can reflect smaller homes, weaker transport, more noise, or fewer local services. The map keeps those trade-offs visible so the cheapest postcode isnt automatically treated as the best option.':
'Az alacsonyabb ár kisebb lakásokat, gyengébb közlekedést, nagyobb zajt vagy kevesebb helyi szolgáltatást tükrözhet. A térkép láthatóan tartja ezeket a kompromisszumokat, így a legolcsóbb irányítószámot nem kezeli automatikusan a legjobb megoldásként.',
'Start from area value, not listing availability':
'Kezdje a terület értékével, ne a rendelkezésre állás felsorolásával',
'Listing portals only show homes for sale today. A postcode-level property price map lets you compare wider areas, understand local price patterns, and avoid missing places where the next suitable listing might appear.':
'A listás portálokon ma csak az eladó lakások jelennek meg. Az irányítószám-szintű ingatlanártérkép segítségével szélesebb területeket hasonlíthat össze, megértheti a helyi ármintákat, és elkerülheti, hogy a következő helyeken megjelenjen a megfelelő hirdetés.',
'A hirdetési portálok csak a ma eladó lakásokat mutatják. Az irányítószám-szintű ingatlanártérkép segítségével szélesebb területeket hasonlíthat össze, megértheti a helyi ármintákat, és elkerülheti, hogy lemaradjon olyan helyekről, ahol a következő megfelelő hirdetés megjelenhet.',
'Use prices alongside real constraints': 'Használja az árakat valós korlátok mellett',
'Budget rarely matters on its own. Perfect Postcode combines price filters with travel time, school quality, property size, energy performance, local environment, and services so your shortlist reflects how you actually want to live.':
'A költségvetés ritkán számít önmagában. A Perfect Postcode az árszűrőket kombinálja az utazási idővel, az iskola minőségével, az ingatlan méretével, az energiahatékonysággal, a helyi környezettel és a szolgáltatásokkal, így a szűkített lista tükrözi, hogyan szeretne ténylegesen élni.',
'What the price data is for': 'Mire szolgálnak az áradatok',
'Use the map to compare areas and spot search candidates. It isnt a valuation, mortgage decision, survey, legal search, or live listing feed.':
'Használja a térképet a területek összehasonlításához és a helyszíni kereséshez. Ez nem értékbecslés, jelzáloghitel-döntés, felmérés, jogi keresés vagy élő lista.',
'Használja a térképet a területek összehasonlítására és a kereséshez szóba jövő helyek megtalálására. Ez nem értékbecslés, jelzáloghitel-döntés, felmérés, jogi keresés vagy élő hirdetésforrás.',
'How to validate a promising area': 'Hogyan érvényesítsünk egy ígéretes területet',
'Once a postcode looks promising, check current listings, sold-price comparables, agent details, flood searches, legal packs, surveys, and local authority information before making a decision.':
'Ha egy irányítószám ígéretesnek tűnik, a döntés meghozatala előtt ellenőrizze az aktuális listákat, az eladási árak összehasonlító adatait, az ügynökök adatait, az árvízkereséseket, a jogi csomagokat, a felméréseket és a helyi hatóságok adatait.',
@ -166,7 +167,7 @@ const hu: Translations = {
'Check one postcode before you spend time on a viewing.':
'Ellenőrizze az egyik irányítószámot, mielőtt a megtekintéssel töltene időt.',
'Explore the property map': 'Fedezze fel az ingatlantérképet',
'Postcode property search': 'Irányítószám ingatlan keresés',
'Postcode property search': 'Irányítószám-alapú ingatlankeresés',
'Find postcodes that match your property search criteria':
'Keresse az ingatlankeresési kritériumoknak megfelelő irányítószámokat',
'Postcode property search - Find areas that match your criteria':
@ -174,17 +175,17 @@ const hu: Translations = {
'Search every postcode by budget, property type, floor area, tenure, commute, schools, crime, broadband, noise, parks and local amenities.':
'Keressen minden irányítószámot költségvetés, ingatlantípus, alapterület, birtokviszony, ingázás, iskolák, bűnözés, szélessáv, zaj, parkok és helyi szolgáltatások alapján.',
'Search every postcode by budget, property type, size, tenure, commute, schools, crime, broadband, noise, parks, and local amenities instead of checking areas one at a time.':
'Keressen minden irányítószámon költségvetés, ingatlantípus, méret, birtoklás, ingázás, iskolák, bűnözés, szélessáv, zaj, parkok és helyi szolgáltatások szerint, ahelyett, hogy egyenként ellenőrizné a területeket.',
'Keressen minden irányítószámon költségvetés, ingatlantípus, méret, tulajdonforma, ingázás, iskolák, bűnözés, szélessáv, zaj, parkok és helyi szolgáltatások szerint, ahelyett, hogy egyenként ellenőrizné a területeket.',
'Filter England-wide postcode data from one map.':
'Szűrje le az angliai irányítószámadatokat egyetlen térképről.',
'Shortlist unfamiliar areas with comparable evidence.':
'Sorolja fel az ismeretlen területeket összehasonlítható bizonyítékokkal.',
'Szűkítse listára az ismeretlen területeket összehasonlítható adatok alapján.',
'Save and share search areas before booking viewings.':
'Mentse és ossza meg a keresési területeket a megtekintések lefoglalása előtt.',
'Turn a broad brief into postcode candidates':
'Változtassa meg a széles tájékoztatót irányítószám jelöltekké',
'Enter the practical constraints first: budget, property size, tenure, travel time, school needs, broadband, and tolerance for road noise or crime levels. The map removes places that fail those constraints and keeps the remaining options comparable.':
'Először adja meg a gyakorlati korlátokat: költségvetés, ingatlan mérete, birtoklási ideje, utazási idő, iskolai igények, szélessáv, valamint a közúti zaj- vagy bűnözési szint toleranciája. A térkép eltávolítja azokat a helyeket, amelyek nem felelnek meg ezeknek a korlátozásoknak, és a fennmaradó lehetőségeket összehasonlíthatóvá teszi.',
'Először adja meg a gyakorlati korlátokat: költségvetés, ingatlan mérete, tulajdonforma, utazási idő, iskolai igények, szélessáv, valamint a közúti zaj és a bűnözési szint elviselhetősége. A térkép eltávolítja azokat a helyeket, amelyek nem felelnek meg ezeknek a korlátoknak, és a fennmaradó lehetőségeket összehasonlíthatóvá teszi.',
'Relax one constraint at a time': 'Egyszerre lazítson egy kényszert',
'When the search becomes too narrow, loosen a single filter and watch which postcodes reappear. This makes compromise explicit instead of relying on guesswork.':
'Ha a keresés túl szűk lesz, lazítson meg egyetlen szűrőt, és figyelje, mely irányítószámok jelennek meg újra. Ez egyértelművé teszi a kompromisszumot ahelyett, hogy a találgatásokra hagyatkozna.',
@ -210,12 +211,12 @@ const hu: Translations = {
'Igen. A térképet úgy tervezték, hogy a gyakorlati korlátoknak megfelelő, ismeretlen területeket is felszínre hozzon, nem csak a már ismert helyeket.',
'Are the results live property listings?': 'Az eredmények élő ingatlanhirdetések?',
'No. The tool compares postcode data and historical/contextual property signals. You still need listing portals for current availability.':
'Nem. Az eszköz összehasonlítja az irányítószámadatokat és a történelmi/kontextuális tulajdonságjeleket. Az aktuális elérhetőséghez továbbra is listáznia kell a portálokat.',
'Nem. Az eszköz az irányítószám-adatokat és a történeti/kontextuális ingatlanjeleket hasonlítja össze. Az aktuális elérhetőséghez továbbra is hirdetési portálokra van szüksége.',
'Manchester property search guide': 'Manchester ingatlankeresési útmutató',
'A regional guide for narrowing a broad search around Greater Manchester.':
'Regionális útmutató a Nagy-Manchester környéki keresés szűkítéséhez.',
'Start a postcode search': 'Indítsa el az irányítószám keresést',
'Commute property search': 'Ingatlan keresés',
'Start a postcode search': 'Indítsa el az irányítószám-keresést',
'Commute property search': 'Ingázás alapú ingatlankeresés',
'Search for places to live by commute time': 'Keressen lakóhelyeket az ingázási idő szerint',
'Commute property search - Find places to live by travel time':
'Ingatlankeresés Keressen lakóhelyeket az utazási idő alapján',
@ -301,7 +302,7 @@ const hu: Translations = {
'Használja az iskolai szűrőket a kutatás szűkítésére, ne pedig a felvételi jogosultság feltételezésére. A minősítéseket, a távolságot, a felvételi kritériumokat és az iskolai kapacitást ellenőrizni kell az aktuális hivatalos forrásokból.',
'Family trade-offs to compare': 'Összehasonlítandó családi kompromisszumok',
'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.':
'Kombináld az iskolákat a parkokkal, az útzajjal, a bűnözéssel, az ingatlan méretével, az ingázással, a szélessávval és az árakkal, hogy a szűkített lista tükrözze az egész lépést.',
'Kombinálja az iskolákat a parkokkal, az útzajjal, a bűnözéssel, az ingatlan méretével, az ingázással, a szélessávval és az árakkal, hogy a szűkített lista tükrözze az egész költözést.',
'Does this show school catchment guarantees?':
'Ez mutatja az iskolai vonzáskörzeti garanciákat?',
'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.':
@ -323,7 +324,7 @@ const hu: Translations = {
'Check postcode-level property prices, EPC data, crime, broadband, road noise, schools, council tax, amenities and travel-time context.':
'Ellenőrizze az irányítószám-szintű ingatlanárakat, az EPC-adatokat, a bűnözést, a szélessávot, az útzajt, az iskolákat, az önkormányzati adót, a kényelmi szolgáltatásokat és az utazási időt.',
'Review property prices, EPC context, crime, broadband, road noise, local amenities, schools, deprivation, council tax, and travel-time data from one postcode-first map.':
'Tekintse át az ingatlanárakat, az EPC-környezetet, a bűnözést, a szélessávot, az utak zaját, a helyi létesítményeket, az iskolákat, a nélkülözést, az önkormányzati adót és az utazási időre vonatkozó adatokat egyetlen irányítószám-először térképen.',
'Tekintse át az ingatlanárakat, az EPC-környezetet, a bűnözést, a szélessávot, az utak zaját, a helyi szolgáltatásokat, az iskolákat, a nélkülözést, az önkormányzati adót és az utazási időre vonatkozó adatokat egyetlen, irányítószám-központú térképen.',
'Check multiple local signals before visiting a street.':
'Ellenőrizze több helyi jelet, mielőtt felkeres egy utcát.',
'Use official and open datasets rather than reputation alone.':
@ -340,13 +341,13 @@ const hu: Translations = {
'Useful before and alongside listing portals':
'Hasznos a hirdetési portálok előtt és mellett',
'Listing photos rarely tell you enough about the surrounding street. Perfect Postcode gives you an evidence-led postcode check before you commit time to a viewing.':
'A felsorolt fotók ritkán mondanak eleget a környező utcáról. A Perfect Postcode bizonyítékokkal vezérelt irányítószám-ellenőrzést tesz lehetővé, mielőtt időt szánna a megtekintésre.',
'A hirdetési fotók ritkán mondanak eleget a környező utcáról. A Perfect Postcode bizonyítékokon alapuló irányítószám-ellenőrzést tesz lehetővé, mielőtt időt szánna a megtekintésre.',
'A screening tool, not professional advice': 'Szűrőeszköz, nem szakmai tanács',
'The data is designed for shortlisting and comparison. Any purchase still needs current listing checks, legal due diligence, flood searches, lender requirements, and survey findings.':
'Az adatok szűkítésre és összehasonlításra készültek. Bármely vásárláshoz továbbra is szükség van az aktuális listázási ellenőrzésekre, a jogi átvilágításra, az árvízkutatásokra, a hitelezői követelményekre és a felmérések eredményeire.',
'What a postcode check can catch': 'Amit egy irányítószám-ellenőrzés elkaphat',
'A postcode check can surface price context, environmental signals, nearby amenities, and other local indicators that are easy to miss in a listing.':
'Az irányítószám-ellenőrzés feltárhatja az árkontextust, a környezeti jelzéseket, a közeli létesítményeket és más olyan helyi mutatókat, amelyeket könnyen el lehet hagyni az adatlapon.',
'Az irányítószám-ellenőrzés feltárhatja az árkontextust, a környezeti jelzéseket, a közeli létesítményeket és más olyan helyi mutatókat, amelyeket egy hirdetésben könnyen át lehet siklani.',
'What a postcode check cant prove': 'Amit az irányítószám-ellenőrzés nem tud bizonyítani',
'It cant confirm the condition of a home, future development, legal title, lender requirements, or current street-level experience. Those still need direct checks.':
'Nem tudja megerősíteni az otthon állapotát, a jövőbeni fejlesztést, a jogcímet, a hitelezői követelményeket vagy a jelenlegi utcai szintű tapasztalatot. Még mindig közvetlen ellenőrzésre van szükségük.',
@ -369,7 +370,7 @@ const hu: Translations = {
'Use postcode-level data to compare Birmingham property prices, commute trade-offs, schools, crime, broadband and local amenities before viewings.':
'Használja az irányítószám-szintű adatokat a birminghami ingatlanárak, az ingázási kompromisszumok, az iskolák, a bűnözés, a szélessáv és a helyi szolgáltatások összehasonlítására a megtekintés előtt.',
'Birmingham searches can change quickly from street to street. Use postcode-level evidence to compare budget, commute, schools, noise, crime, and local services before deciding where to watch listings.':
'A birminghami keresések gyorsan változhatnak utcáról utcára. Használjon irányítószám-szintű bizonyítékokat a költségvetés, az ingázás, az iskolák, a zaj, a bűnözés és a helyi szolgáltatások összehasonlítására, mielőtt eldönti, hol nézze meg az adatokat.',
'A birminghami keresések gyorsan változhatnak utcáról utcára. Használjon irányítószám-szintű bizonyítékokat a költségvetés, az ingázás, az iskolák, a zaj, a bűnözés és a helyi szolgáltatások összehasonlítására, mielőtt eldönti, hol kövesse a hirdetéseket.',
'Start with commute corridors': 'Kezdje az ingázási folyosókkal',
'Choose the destination that matters, such as a workplace, station, university, or hospital, then compare reachable postcodes by transport mode and travel-time band.':
'Válassza ki a fontos úti célt, például munkahelyet, állomást, egyetemet vagy kórházat, majd hasonlítsa össze az elérhető irányítószámokat közlekedési mód és utazási idősáv szerint.',
@ -385,7 +386,7 @@ const hu: Translations = {
'Keep family and environment trade-offs visible':
'A család és a környezet közötti kompromisszumok láthatóak legyenek',
'Layer school context, parks, road noise, broadband, and crime signals on top of the property filters. That makes it easier to decide which compromises are acceptable.':
'Az iskolai környezetet, a parkokat, az útzajokat, a szélessávot és a bűnjeleket az ingatlanszűrők tetejére helyezze. Ez megkönnyíti annak eldöntését, hogy mely kompromisszumok elfogadhatók.',
'Az iskolai környezetet, a parkokat, az útzajt, a szélessávot és a bűnözési jelzéseket rétegezze az ingatlanszűrők fölé. Ez megkönnyíti annak eldöntését, hogy mely kompromisszumok elfogadhatók.',
'Can Perfect Postcode tell me the best area in Birmingham?':
'Meg tudja mondani a Perfect Postcode Birmingham legjobb környékét?',
'No tool can decide the best area for every buyer. It helps compare postcodes against your own constraints so you can build a better shortlist.':
@ -407,25 +408,25 @@ const hu: Translations = {
'Compare Manchester-area postcodes by budget, commute, property type, schools, broadband, crime, noise and amenities before booking viewings.':
'Hasonlítsa össze Manchester környéki irányítószámait költségvetés, ingázás, ingatlantípus, iskolák, szélessáv, bűnözés, zaj és szolgáltatások szerint, mielőtt lefoglalná a megtekintéseket.',
'A Manchester-area search can span city-centre, suburban, and commuter options. Perfect Postcode helps keep each postcode comparable against the same property and daily-life constraints.':
'A Manchester környéki keresés kiterjedhet a városközpontra, a külvárosra és az ingázási lehetőségekre. A Perfect Postcode segítségével az egyes irányítószámok összehasonlíthatók ugyanazokkal a tulajdonságokkal és a mindennapi élet korlátozásával.',
'A Manchester környéki keresés kiterjedhet a városközpontra, a külvárosra és az ingázási lehetőségekre. A Perfect Postcode segítségével az egyes irányítószámok összehasonlíthatók ugyanazokkal az ingatlan- és mindennapi élet-korlátokkal.',
'Use travel time to define the real search area':
'Használja az utazási időt a valódi keresési terület meghatározásához',
'Start from the destinations that matter, then compare reachable postcodes rather than assuming every nearby place has the same practical journey.':
'Kezdje a fontos célpontoktól, majd hasonlítsa össze az elérhető irányítószámokat, ahelyett, hogy azt feltételezné, hogy minden közeli hely ugyanazt a gyakorlati utat járja be.',
'Induljon ki a fontos célpontokból, majd hasonlítsa össze az elérhető irányítószámokat, ahelyett, hogy azt feltételezné, hogy minden közeli helyről ugyanúgy lehet eljutni oda.',
'Compare housing requirements before lifestyle preferences':
'Hasonlítsa össze a lakhatási igényeket az életmódbeli preferenciák előtt',
'Filter by property type, floor area, tenure, and price before judging amenities. That keeps the shortlist grounded in homes that could realistically work.':
'Szűrje az ingatlan típusa, alapterülete, birtoklási ideje és ár alapján, mielőtt megítélné a felszereltséget. Ezáltal a szűkített lista olyan otthonokra épül, amelyek reálisan működhetnek.',
'Check local context consistently': 'Következetesen ellenőrizze a helyi környezetet',
'Use broadband, crime, road noise, parks, schools, and amenities as comparable signals. Then validate the strongest candidates with current local checks.':
'Használja a szélessávot, a bűnözést, az útzajt, a parkokat, iskolákat és létesítményeket hasonló jelként. Ezután érvényesítse a legerősebb jelölteket az aktuális helyi ellenőrzésekkel.',
'Használja a szélessávot, a bűnözést, az útzajt, a parkokat, iskolákat és létesítményeket összehasonlítható mutatókként. Ezután érvényesítse a legerősebb jelölteket az aktuális helyi ellenőrzésekkel.',
'Can I compare Manchester suburbs with city-centre postcodes?':
'Összehasonlíthatom Manchester külvárosait a városközpont irányítószámaival?',
'Yes. Use the same budget, property, commute, and local-context filters across both so trade-offs remain visible.':
'Igen. Ugyanazt a költségkeret-, tulajdon-, ingázási és helyi kontextusszűrőt használja mindkettőben, így a kompromisszumok láthatóak maradnak.',
'Does this include live listings?': 'Ez magában foglalja az élő listákat?',
'No. Use it to decide where to search, then use listing portals for current homes for sale.':
'Nem. Használja annak eldöntésére, hogy hol keressen, majd használja az aktuális eladó lakások listázási portálját.',
'Nem. Használja annak eldöntésére, hogy hol keressen, majd a jelenleg eladó otthonokhoz használja a hirdetési portálokat.',
'Move from a broad search brief to specific postcode candidates.':
'Térjen át a széles körű keresési összefoglalóról a konkrét irányítószám jelöltekre.',
'Data sources': 'Adatforrások',
@ -433,7 +434,7 @@ const hu: Translations = {
'Tekintse át a tulajdonságok és a helyi kontextus összehasonlításához használt adatkészleteket.',
'Check a single postcode before arranging a viewing.':
'A megtekintés megszervezése előtt ellenőrizze az irányítószámot.',
'Compare Manchester postcodes': 'Hasonlítsa össze a Manchester irányítószámait',
'Compare Manchester postcodes': 'Hasonlítsa össze a manchesteri irányítószámokat',
'How to compare Bristol postcodes before a property search':
'Hogyan hasonlítsuk össze Bristol irányítószámait ingatlankeresés előtt',
'Bristol property search - Compare postcodes by commute and price':
@ -445,7 +446,7 @@ const hu: Translations = {
'Make commute constraints explicit': 'Tegye egyértelművé az ingázási korlátozásokat',
'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.':
'Ha fontos a központ, állomás, kórház, egyetem vagy üzleti park elérése, először használja az utazási idő szűrőit, majd hasonlítsa össze a fennmaradó irányítószámokat ingatlanadatok alapján.',
'Compare value, not just headline price': 'Hasonlítsa össze az értéket, ne csak a árat',
'Compare value, not just headline price': 'Hasonlítsa össze az értéket, ne csak a kiinduló árat',
'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.':
'Használja együtt az ár-, ingatlantípus- és alapterület-szűrőket. Ez segít megkülönböztetni az alacsonyabb költségű területeket azoktól a területektől, amelyek egyszerűen kisebb vagy eltérő otthonokat tartalmaznak.',
'Screen environmental and local-service signals':
@ -459,16 +460,16 @@ const hu: Translations = {
'Can this tell me whether a listing is good value?':
'Ez meg tudja mondani, hogy egy hirdetés jó érték-e?',
'It can provide area context, but a specific listing still needs comparable sales, condition checks, survey findings, and professional advice where appropriate.':
'Megadhatja a területi kontextust, de egy adott adatlapnak továbbra is szüksége van összehasonlítható eladásokra, állapotellenőrzésekre, felmérési eredményekre és adott esetben szakmai tanácsra.',
'Megadhatja a területi kontextust, de egy adott hirdetéshez továbbra is szükség van összehasonlítható eladásokra, állapotellenőrzésekre, felmérési eredményekre és adott esetben szakmai tanácsra.',
'Search by reachable postcodes before refining by budget and local context.':
'Keressen elérhető irányítószámok alapján, mielőtt finomítaná a költségvetés és a helyi kontextus alapján.',
'Understand price patterns before setting listing alerts.':
'Ismerje meg az ármintákat, mielőtt beállítja a listára vonatkozó figyelmeztetéseket.',
'Ismerje meg az ármintákat, mielőtt beállítja a hirdetési értesítéseket.',
'Privacy and security': 'Adatvédelem és biztonság',
'How account and saved-search data is handled in the product.':
'Hogyan történik a fiók és a mentett keresési adatok kezelése a termékben.',
'Compare Bristol postcodes': 'Hasonlítsa össze a Bristol irányítószámait',
'Trust and coverage': 'Bizalom és fedezet',
'Compare Bristol postcodes': 'Hasonlítsa össze a bristoli irányítószámokat',
'Trust and coverage': 'Megbízhatóság és lefedettség',
'Perfect Postcode data sources and coverage': 'Perfect Postcode adatforrások és lefedettség',
'Perfect Postcode data sources - Property, schools, commute and local context':
'Perfect Postcode-adatforrások ingatlanok, iskolák, ingázás és helyi környezet',
@ -489,7 +490,7 @@ const hu: Translations = {
'Travel-time data': 'Utazási idő adatok',
'Travel-time filters are designed for consistent area comparison. Route availability, disruption, parking, walking access, and timetable details should be verified before committing to an area.':
'Az utazási idő szűrőit a területek következetes összehasonlítására tervezték. Az útvonal elérhetőségét, a fennakadásokat, a parkolást, a gyalogos hozzáférést és a menetrend részleteit ellenőrizni kell, mielőtt elkötelezné magát egy adott területen.',
'Why does coverage focus on England?': 'Miért fókuszál a tudósítás Angliára?',
'Why does coverage focus on England?': 'Miért fókuszál a lefedettség Angliára?',
'Several core property, education, and local-context datasets are jurisdiction-specific. England coverage keeps comparisons more consistent.':
'Számos alapvető tulajdon, oktatás és helyi kontextusú adatkészlet joghatóság-specifikus. Az angol lefedettség következetesebbé teszi az összehasonlításokat.',
'How should I handle stale or missing data?':
@ -509,7 +510,7 @@ const hu: Translations = {
'Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.':
'Ismerje meg, hogyan használhatja az irányítószám-szűrőket, az ingatlanbecsléseket, az utazási időre vonatkozó adatokat, az iskolai környezetet és a helyi jelzéseket lakásvásárlási szűkített eszközként.',
'Perfect Postcode is designed to make area shortlisting more evidence-led. It doesnt replace estate agents, surveyors, conveyancers, lenders, school admissions teams, or local authority checks.':
'A Perfect Postcode célja, hogy a területek szűkített listáját több bizonyítékra irányítsa. Nem helyettesíti az ingatlanügynököket, a földmérőket, a szállítókat, a hitelezőket, az iskolai felvételi csoportokat vagy a helyi hatóságok ellenőrzéseit.',
'A Perfect Postcode célja, hogy a területek szűkített listáját bizonyítékokra alapozza. Nem helyettesíti az ingatlanügynököket, a földmérőket, az ingatlanjogászokat, a hitelezőket, az iskolai felvételi csoportokat vagy a helyi hatóságok ellenőrzéseit.',
'Start with hard constraints': 'Kezdje kemény korlátokkal',
'Begin with non-negotiables such as budget, property type, floor area, commute time, and essential services. This removes impossible postcodes before softer preferences are considered.':
'Kezdje a nem alku tárgyát képező dolgokkal, mint például a költségvetés, az ingatlan típusa, az alapterület, az ingázási idő és az alapvető szolgáltatások. Ez eltávolítja a lehetetlen irányítószámokat, mielőtt a lágyabb beállításokat figyelembe venné.',
@ -548,7 +549,7 @@ const hu: Translations = {
'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'A mentett keresések és megosztott hivatkozások bejelentkezett használatra szolgálnak. Nem szerepelnek a nyilvános webhelytérképen, és nyilvános tartalomként nem térképezhetők fel.',
'Search measurement without exposing private data':
'A mérési adatok keresése személyes adatok felfedése nélkül',
'Keresésmérés a személyes adatok felfedése nélkül',
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldnt become indexable landing pages.':
'A keresőoptimalizálás mérésének nyilvános oldalakon kell történnie, összesített elemzési és Search Console-adatok felhasználásával. A privát lekérdezési paraméterek és a fióknézetek nem válhatnak indexelhető céloldalakká.',
'Are saved searches listed in the sitemap?':
@ -665,8 +666,12 @@ const hu: Translations = {
clearAll: 'Összes törlése',
clearAllTitle: 'Összes szűrő törlése?',
clearAllSavePrompt: 'Szeretnéd menteni a jelenlegi szűrőket a törlés előtt?',
clearAllUpdatePrompt:
'Frissíted a(z) <strong>{{name}}</strong> keresést a jelenlegi szűrőkkel törlés előtt?',
saveAndClear: 'Mentés és törlés',
updateAndClear: 'Frissítés és törlés',
clearWithoutSaving: 'Törlés mentés nélkül',
clearWithoutUpdating: 'Törlés frissítés nélkül',
filtersOut: '{{value}} elemet kiszűr',
schoolType: 'Iskolatípus',
schoolRating: 'Iskolai értékelés',
@ -689,7 +694,7 @@ const hu: Translations = {
'Kezdd a feltétlenül szükséges feltételekkel, majd add hozzá a kívánalmakat. A térkép szűkül, ahogy szűrőket adsz hozzá. A megmaradó területek a legjobb találatok.',
step1Title: 'Költségvetés és alapok',
step1Desc: '(ártartomány, alapterület, ingatlantípus)',
step2Title: 'Ingazás',
step2Title: 'Ingázás',
step2Desc: '(utazási idő a munkahelyre autóval, kerékpárral vagy tömegközlekedéssel)',
step3Title: 'Biztonság',
step3Desc: '(bűnözési arányok, zajszintek, talajstabilitás)',
@ -736,7 +741,7 @@ const hu: Translations = {
bicycleDesc: ' kerékpárral, kerékpárbarát útvonalakon.',
walkingDesc: ' gyalog, sétálóutakon és járdákon.',
mainDesc: 'Megmutatja az utazási időt a kiválasztott célponttól az egyes területekig.',
sliderHint: 'Használd a csúszkát a maximális ingazási idő beállításához.',
sliderHint: 'Használd a csúszkát a maximális ingázási idő beállításához.',
},
// ── AI Filter ──────────────────────────────────────
@ -815,10 +820,13 @@ const hu: Translations = {
showAllStatsFallback:
'Váltson az összes ingatlanra, hogy aktív szűrők nélkül tekintse át ezt a területet.',
showAllStats: 'Összes ingatlan mutatása',
closestBlockingFilters: 'A területet kizáró legközelebbi szűrők',
closestBlockingFilters: 'A terület bevonásához legközelebbi módosítások',
lowerMinTo: 'Minimum csökkentése erre: {{value}}',
raiseMaxTo: 'Maximum növelése erre: {{value}}',
allowCategory: '{{value}} engedélyezése',
missingFilterValue:
'Ehhez a szűrőhöz nincs érték; távolítsa el, vagy engedélyezze a hiányzó értékeket',
noFilterDataShort: 'Nincs adat',
travelTo: 'Utazás ide: {{destination}}',
viewProperties: '{{count}} ingatlan megtekintése',
viewPropertiesShort: 'Ingatlanok megtekintése',
@ -879,23 +887,23 @@ const hu: Translations = {
// ── Mobile Drawer ──────────────────────────────────
mobileDrawer: {
closeDrawer: 'Fiók bezárása',
closeDrawer: 'Panel bezárása',
},
// ── Home Page ──────────────────────────────────────
home: {
heroEyebrow: 'Először találd meg, hol érdemes keresni',
heroTitle1: 'Ne keress tovább',
heroTitle2: 'rossz helyeken',
heroTitle3: 'Mielőtt a hirdetések beszűkítik a keresést.',
heroEyebrow: 'Vevőknek, akik azt kérdezik: „hol is kezdjem?”',
heroTitle1: 'Találd meg az irányítószámokat',
heroTitle2: 'amelyek illenek az életedhez',
heroTitle3: 'Nem csak azokat a környékeket, amelyeket már ismersz.',
heroSubtitle:
'Találd meg azokat az irányítószámokat, ahol a költségvetésed, az ingázásod és a mindennapjaid összeérnek.',
'A londoni városrészeken, ingázó településeken és regionális városokon át Angliában túl sok hely van ahhoz, hogy egyenként kutasd át őket.',
heroDescription:
'A Perfect Postcode először minden irányítószámot megszűr, így csak ott mész megtekintésre, ahol tényleg működhet.',
exploreTheMap: 'Mutasd, hol keressek',
seeTheDifference: 'Demó megtekintése',
productDemoLabel: 'Nézd meg, hogyan találod meg először, hol keress',
playProductDemo: '„Hol keress” demó lejátszása',
'Állítsd be a költségvetést, ingázást, iskolákat, biztonságot, zajt, internetet és életstílust. A Perfect Postcode átnézi Anglia irányítószámait, és megmutatja azokat a helyeket is, amelyeket sosem írtál volna be egy ingatlanportálra.',
exploreTheMap: 'Megfelelő irányítószámok keresése',
seeTheDifference: 'Így működik',
productDemoLabel: 'Perfect Postcode termékdemó',
playProductDemo: 'Perfect Postcode termékdemó lejátszása',
scrollToProductDemo: 'Ugrás a termékdemóhoz',
showcaseHeader: 'Így működik',
showcaseContext: 'Így működik a Perfect Postcode',
@ -903,43 +911,44 @@ const hu: Translations = {
showcaseFeatureNoiseShort: 'Zaj',
showcaseFeatureSchoolsShort: 'Iskolák',
showcaseFeatureTravelShort: 'Utazás',
showcaseGoodPrimariesNearby: '{{count}}+ jó vagy kiváló általános iskola a közelben',
showcaseWithinRail: '{{count}} percen belül egy állomástól',
showcaseMatchingHomesLabel: 'Illeszkedő irányítószámok',
showcaseMatchingHomes: '{{value}} illeszkedő irányítószám',
showcaseGoodPrimariesNearby: '{{count}}+ jó általános iskola a közelben',
showcaseWithinRail: '{{count}} percen belül vasúthoz',
showcaseMatchingHomesLabel: 'Illeszkedő otthonok',
showcaseMatchingHomes: '{{value}} illeszkedő otthon',
showcaseMedianPrice: '{{value}} medián',
showcaseJourneyRoutes: 'Útvonalak',
showcaseNearby: '{{value}} a közelben',
showcasePoliticalVoteShare: 'Politikai szavazatarány',
showcaseLotsMore: 'További környékadatok',
showcaseLotsMore: '...és még sok más',
showcaseMinutes: '{{count}} perc',
showcaseSendShortlist: 'Küldd el a szűkített listát',
showcaseDownloadXlsx: '.xlsx letöltése',
showcaseTopThree: 'Top 3',
showcaseScoutBullet1: 'Ellenőrizd az utcát, mielőtt hirdetésfigyelőkre hagyatkozol.',
showcaseScoutBullet1:
'Járd be az utcákat, mielőtt a hirdetéskeresés leszűkíti a lehetőségeidet.',
showcaseScoutBullet2: 'Valódi bejárati ajtótól teszteld az ingázást, ne csak városrésznévből.',
showcaseScoutBullet3: 'Bizonyítékokkal a kezedben hasonlítsd össze a megtekintéseket.',
showcaseStep1Tab: 'Szűrés',
showcaseStep1Title: 'Állítsd be, minek kell működnie',
showcaseStep1Title: 'A homályos igényekből pontos keresés lesz',
showcaseStep1Body:
'Add hozzá a költségvetést, ingázást, iskolákat, biztonságot, zajt és helyi részleteket. Figyeld, ahogy a rossz irányítószámok kiesnek.',
'Állítsd be, mi számít, és pontosan lásd, hogy minden feltétel hány nem megfelelő irányítószámot zár ki a keresésből.',
showcaseStep1Chip1: 'Csendes utcák',
showcaseStep1Chip2: 'Jó általános iskolák a közelben',
showcaseStep1Chip2: 'Kiváló általános iskolák',
showcaseStep1Chip3: '£500k alatt',
showcaseStep1VennCenter: 'Mindhárom feltételt teljesítő irányítószámok',
showcaseStep2Tab: 'Egyeztetés',
showcaseStep2Title: 'Nézd meg a fennmaradó helyeket',
showcaseStep2Title: 'A térkép olyan helyeket hoz felszínre, amelyeket be sem írtál volna',
showcaseStep2Body:
'Gyakorlati ellenőrzések alapján keress, ne ismerős nevek szerint. A térkép megmutatja, mely irányítószám-klasztereket érdemes először megnézni.',
'Ismert területnevek helyett illeszkedés alapján pásztázd végig Angliát. A rejtett, jó lehetőségek láthatóvá válnak, mielőtt a hirdetési portálok leszűkítenék a gondolkodásodat.',
showcaseStep2Region: 'Nagy-London',
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseStep2ClustersLabel: 'Találati klaszterek',
showcaseStep3Tab: 'Vizsgálat',
showcaseStep3Title: 'Ellenőrizd a bizonyítékokat',
showcaseStep3Title: 'Nézd meg, miért került be egy irányítószám',
showcaseStep3Body:
'Nyiss meg egy irányítószámot, és megtekintés előtt nézd meg az árat, ingázást, iskolákat, bűnözést, internetet és kompromisszumokat.',
showcaseStep3HeaderArea: 'Szűkített irányítószám',
showcaseStep3HeaderFit: 'Mi működik',
'Nyiss meg bármelyik megfelelő területet, és egy panelen ellenőrizd az árakat, biztonságot, iskolákat, internetet és kompromisszumokat, mielőtt rászánsz egy hétvégét.',
showcaseStep3HeaderArea: 'A te tökéletes irányítószámod',
showcaseStep3HeaderFit: 'Környékadatok',
showcaseStep3Stat1Label: 'Eladási ár trend',
showcaseStep3Stat2Label: 'Bűnözési ráta',
showcaseStep3Stat2Value: 'Borough-átlag alatt',
@ -949,33 +958,34 @@ const hu: Translations = {
showcaseStep3Stat5Label: 'Általános iskolák',
showcaseStep3Stat5Value: '3 „outstanding” 1 mérföldön belül',
showcaseStep4Tab: 'Felderítés',
showcaseStep4Title: 'Vidd ki a listát az utcára',
showcaseStep4Title: 'Nézd meg személyesen',
showcaseStep4Body:
'Exportáld az ellenőrzésre érdemes irányítószámokat, próbáld ki az ingázást, járd be az utcákat, és mentett kontextussal hasonlítsd össze a megtekintéseket.',
'Vigyél magaddal három megalapozott kiindulópontot a való világba. Sétáld be az utcákat, próbáld ki az ingázást, és kontextussal hasonlítsd össze a megtekintéseket.',
showcaseStep4FileName: 'areas-to-scout.xlsx',
showcaseStep4ExportLabel: 'Exportálás Excelbe',
showcaseStep4ColPostcode: 'Irányítószám',
showcaseStep4ColScore: 'Egyezés',
showcaseStep4ColCommute: 'Ingázás',
showcaseStep4ColPrice: 'Medián eladási ár',
showcaseStep4Conclusion: 'Exportálj egy szűkített listát, és kezdd el ellenőrizni az utcákat.',
statProperties: 'HM Land Registry eladás',
statFilters: 'mód a térkép szűkítésére',
showcaseStep4Conclusion: 'Innen már el tudod indítani a keresést.',
statProperties: 'korábbi eladás',
statFilters: 'kombinálható szűrő',
statEvery: 'Minden',
statPostcodeInEngland: 'aktív irányítószám Angliában',
ourPhilosophy: 'Ne azokkal a városokkal kezdj, amelyeket már ismersz.',
statPostcodeInEngland: 'irányítószám Angliában',
ourPhilosophy: 'Indulj ki abból, ami számít, majd találd meg a megfelelő irányítószámot',
philosophyP1:
'A legtöbb keresés egy helynévvel indul, aztán reméli, hogy megjelennek a jó otthonok. Ez kihagyja a nehezebb kérdést: mely helyeken érdemes valójában keresni?',
'A legtöbb ingatlanoldal először azt kérdezi, hol szeretnél élni. Londonban ez különösen nehéz, de ugyanez a probléma egész Angliában megjelenik: a vevők néhány ismert helyből indulnak ki, majd külön füleken ellenőrzik az ingázást, iskolákat, bűnözést, Street View-t, internetet és eladási árakat.',
philosophyP2:
'A Perfect Postcode még a hirdetési oldal előtt indul. Állítsd be, mit kell támogatnia egy helynek, majd nézd meg először azokat az irányítószámokat, amelyek megérdemlik a figyelmedet.',
'A Perfect Postcode megfordítja a keresést. Mondd meg a térképnek, mi számít, és megmutatja a megfelelő irányítószámokat, indoklással együtt. Előbb az adatok, aztán a helyszíni benyomás.',
streetTitle: 'A helyek utcáról utcára változnak',
streetIntro:
'Az állomás jó oldala, egy zajos út vagy egyetlen iskolakörzet is megváltoztathatja a keresést. A területnevek mindezt elsimítják.',
streetCard1Title: 'Lépj ki az ismerős nevek csapdájából',
streetCard1Body: 'Találj irányítószám-szintű egyezéseket a már listázott helyeken kívül.',
streetCard2Title: 'Ismerd meg a kompromisszumokat, mielőtt elindulsz',
'A nagy környéknevek elrejtik a fontos részleteket: az állomás melyik oldalát, az útzajt, az iskolákat, a pontos ingázást és a valódi eladási árakat.',
streetCard1Title: 'Találd meg a kihagyott környékeket',
streetCard1Body:
'Hozd felszínre azokat az irányítószámokat, amelyek megfelelnek a feltételeidnek, ne csak ismert nevekre vagy ajánlásokra hagyatkozz.',
streetCard2Title: 'Lásd a kompromisszumokat megtekintés előtt',
streetCard2Body:
'Megtekintések foglalása előtt ellenőrizd az árat, ingázást, zajt, iskolákat, biztonságot, internetet és közeli szolgáltatásokat.',
'Hasonlítsd össze az árat, méretet, ingázást, biztonságot, iskolákat, internetet, zajt és energiahatékonyságot, mielőtt hétvégéket töltesz megtekintésekkel.',
othersVs: 'Mások vs.',
checkMyPostcode: 'Ingatlanportálok',
areaGuides: 'Irányítószám-riportok',
@ -985,11 +995,11 @@ const hu: Translations = {
compAreaDataSub: '(bűnözés, iskolák, zaj, internet, szolgáltatások)',
compPropertyData: 'Ingatlanszintű előzmények',
compPropertyDataSub: '(eladási árak, EPC, alapterület, becsült érték)',
compFilters: 'Költségvetés, ingázás, iskolák, biztonság és helyi adatok együtt',
compFiltersSub: '(költségvetés + ingázás + iskolák + biztonság + helyi kontextus)',
ctaTitle: 'Találd meg, hol érdemes keresni, mielőtt megtekintéseket foglalsz.',
compFilters: '56 együtt működő szűrő',
compFiltersSub: '(nem egy irányítószám vagy hirdetés egyszerre)',
ctaTitle: 'Ne találgasd, hol vegyél.',
ctaDescription:
'Készíts irányítószám-listát abból, ami számít, majd ellenőrizd személyesen az utcákat.',
'Készíts listát olyan irányítószámokból, amelyek illenek a valós életedhez, majd nézd meg őket személyesen.',
},
// ── Pricing Page ───────────────────────────────────
@ -1065,7 +1075,7 @@ const hu: Translations = {
dsIodName: 'Angol Deprivációs Mutatók 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse:
'Országos deprivációs percentilisek jövedelem, foglalkoztatottság, oktatás, egészség, bűnözés és lakókörnyezet területén Anglia minden szomszédságára.',
'Országos deprivációs percentilisek jövedelem, foglalkoztatottság, oktatás, egészség, bűnözés és lakókörnyezet területén Anglia minden lakókörzetére.',
dsEthnicityName: 'Népesség etnikai megoszlás szerint (2021-es népszámlálás)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
@ -1199,7 +1209,7 @@ const hu: Translations = {
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Tároltok rólam személyes adatokat?',
faqPrivacy1A:
'Az ingatlan- és környékinformációk nem tartalmazzák a személyes adataidat. Ha fiókot hozol létre, csak a szolgáltatáshoz szükséges adatokat tároljuk, például e-mail címet, hozzáférési állapotot, hírlevél-választást, mentett kereséseket, mentett ingatlanokat és a Stripe által kezelt fizetéseket. A fiókadatokat az Egyesült Királyság adatvédelmi törvényei szerint kezeljük.',
'Az ingatlan- és környékinformációk nem tartalmazzák a személyes adataidat. Ha fiókot hozol létre, csak a szolgáltatáshoz szükséges adatokat tároljuk, például e-mail címet, hozzáférési állapotot, hírlevél-választást, mentett kereséseket, megosztott hivatkozásokat és a Stripe által kezelt fizetési nyilvántartásokat. A fiókadatokat az Egyesült Királyság adatvédelmi törvényei szerint kezeljük.',
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'Mit mutat ez, amit a hirdetési portálok általában nem?',
faqWhy1A:
@ -1224,13 +1234,13 @@ const hu: Translations = {
// FAQ items — Tips and Tricks
faqTips1Q: 'Hogyan nézhetek meg egy szűrőt a térképen?',
faqTips1A:
'Kattints a szem ikonra egy szűrő vagy jellemző mellett, és a térkép az adott elem alapján színeződik. Az aktív szűrők megmaradnak, így gyorsan összehasonlíthatsz egy dolgot, például árat, ingázási időt, iskolákat, bűnözést vagy zajt a lista módosítása nélkül.',
'Kattints a Színezés gombra egy szűrő vagy jellemző mellett, hogy a térkép az adott elem alapján színeződjön. Az aktív szűrők megmaradnak, így gyorsan összehasonlíthatsz egy dolgot, például árat, ingázási időt, iskolákat, bűnözést vagy zajt a lista módosítása nélkül.',
faqTips2Q: 'Honnan tudom, mit jelent egy szűrő?',
faqTips2A:
'Kattints az i információ gombra egy szűrő vagy jellemző mellett, hogy rövid magyarázatot kapj arról, mit jelent és hogyan olvasd. A térkép egyes részeinek, például az utazási idő kártyáknak, saját információ gombjuk is van.',
'Kattints az Adat gombra egy szűrő vagy jellemző mellett, hogy rövid magyarázatot kapj arról, mit jelent és hogyan olvasd. A térkép egyes részeinek, például az utazási idő kártyáknak, saját adatmagyarázatuk is van.',
faqTips3Q: 'Hogyan frissíthetem a térkép színeit?',
faqTips3A:
'Amikor egy szem előnézet színezi a térképet, a jelmagyarázat Színskála visszaállítása gombjával frissítheted az aktuálisan látott eredmények színeit. Ez hasznos térképmozgatás, nagyítás vagy szűrőmódosítás után.',
'Amikor egy jellemző színezi a térképet, a jelmagyarázatban a Színskála visszaállítása gombbal frissítheted az aktuálisan látott eredmények színeit. Ez hasznos térképmozgatás, nagyítás vagy szűrőmódosítás után.',
},
// ── Account Page ───────────────────────────────────
@ -1259,6 +1269,8 @@ const hu: Translations = {
deleteSearch: 'Keresés törlése',
deleteSearchConfirm:
'Biztosan törölni szeretnéd ezt a mentett keresést? Ez nem vonható vissza.',
isBeingUpdated: '<strong>{{name}}</strong> frissítése folyamatban',
updating: 'Frissítés...',
},
// ── Invites Page ───────────────────────────────────
@ -1358,6 +1370,7 @@ const hu: Translations = {
'Property prices': 'Ingatlanárak',
Transport: 'Közlekedés',
Education: 'Oktatás',
'Defining characteristics': 'Meghatározó jellemzők',
'Area development': 'Területi fejlődés',
Crime: 'Bűnözés',
Neighbours: 'Szomszédok',

View file

@ -4,6 +4,7 @@ const zh: Translations = {
// ── Common ──────────────────────────────────────────
common: {
save: '保存',
update: '更新',
cancel: '取消',
close: '关闭',
delete: '删除',
@ -22,7 +23,7 @@ const zh: Translations = {
none: '无',
viewDataSource: '查看数据来源',
total: '总计',
min: '分钟',
min: '最小',
max: '最大',
or: '或',
area: '区域',
@ -99,142 +100,142 @@ const zh: Translations = {
relatedPagesDesc: '通过这些内部链接,从另一个角度比较同一套房产搜索流程。',
pages: {
'Property price map': '房产价格地图',
'Compare property prices across every postcode in England': '比较英格兰每个邮政编码的房价',
'Compare property prices across every postcode in England': '比较英格兰每个邮的房价',
'Property price map for England - Compare postcodes before viewing':
'英格兰房地产价格地图 - 查看前比较邮政编码',
'英格兰房地产价格地图 - 看房前比较邮编',
'Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.':
'在搜索房源之前,比较各个英格兰邮政编码的售价、估计当前价值、每平方米价格和当地情况。',
'在搜索房源之前,比较各个英格兰邮的售价、估计当前价值、每平方米价格和当地情况。',
'Perfect Postcode maps sold prices, estimated current value, price per square metre, property type, floor area, tenure, and local context so buyers can find realistic search areas before opening listing portals.':
'Perfect Postcode映射售价、估计当前价值、每平方米价格、房产类型、建筑面积、保有权和当地背景,以便买家在打开房源平台之前找到实际的搜索区域。',
'Perfect Postcode 在地图上展示成交价、估计当前价值、每平方米价格、房产类型、建筑面积、产权和当地背景,让买家在打开房源平台之前找到切合实际的搜索区域。',
'Screen historical sale prices and current-value estimates by postcode.':
'按邮政编码筛选历史销售价格和当前价值估计。',
'按邮筛选历史销售价格和当前价值估计。',
'Compare value with commute, schools, broadband, crime, noise, and amenities.':
'将价值与通勤、学校、宽带、犯罪、噪音和便利设施进行比较。',
'Build a shortlist before spending weekends on viewings.':
'在周末观看之前先建立一个候选名单。',
'在花周末时间看房之前先建立候选名单。',
'Find postcodes that fit the budget before listings appear':
'在列表出现之前查找符合预算的邮政编码',
'在房源出现之前找到符合预算的邮编',
'Start with a maximum price and property type, then colour the map by price per square metre or estimated current price. This helps reveal areas where similar homes have historically traded within reach, even when there are no live listings today.':
'从最高价格和房产类型开始,然后按每平方米的价格或估计的当前价格为地图着色。这有助于揭示历史上类似房屋交易过的区域,即使现在没有实时挂牌房源。',
'先设置最高价格和房产类型,然后按每平方米价格或估计当前价格为地图着色。即使目前没有实时房源,也能揭示历史上类似房屋曾以可负担价格成交的区域。',
'Filter by last known sale price, estimated current value, property type, tenure, and floor area.':
'按最后已知的销售价格、估计当前价值、房产类型、保有权和建筑面积进行筛选。',
'按最近一次成交价、估计当前价值、房产类型、产权和建筑面积进行筛选。',
'Compare nearby postcodes using the same criteria instead of relying on area reputation.':
'使用相同的标准比较附近的邮政编码,而不是依赖区域声誉。',
'使用相同的标准比较附近的邮编,而不是依赖区域口碑。',
'Use the results as a shortlist for listing alerts, local research, and viewings.':
'使用结果作为列出警报、本地研究和查看的候选列表。',
'将结果作为候选名单,用于设置房源提醒、当地调研和看房安排。',
'Separate cheap from good value': '区分便宜和物有所值',
'A lower price can reflect smaller homes, weaker transport, more noise, or fewer local services. The map keeps those trade-offs visible so the cheapest postcode isnt automatically treated as the best option.':
'较低的价格可能反映出房屋较小、交通较弱、噪音较大或本地服务较少。地图使这些权衡显而易见,因此最便宜的邮政编码不会自动被视为最佳选择。',
'Start from area value, not listing availability': '从面积价值开始,而不是列出可用性',
'较低的价格可能反映出房屋较小、交通较弱、噪音较大或本地服务较少。地图使这些权衡显而易见,因此最便宜的邮不会自动被视为最佳选择。',
'Start from area value, not listing availability': '从区域价值出发,而不是看是否有在售房源',
'Listing portals only show homes for sale today. A postcode-level property price map lets you compare wider areas, understand local price patterns, and avoid missing places where the next suitable listing might appear.':
'房源平台仅显示今天待售的房屋。邮政编码级别的房产价格地图可让您比较更广泛的区域,了解当地的价格模式,并避免遗漏下一个合适的列表可能出现的位置。',
'房源平台仅显示今天待售的房屋。邮级别的房产价格地图可让您比较更广泛的区域,了解当地的价格模式,并避免遗漏下一个合适的列表可能出现的位置。',
'Use prices alongside real constraints': '使用价格和实际限制',
'Budget rarely matters on its own. Perfect Postcode combines price filters with travel time, school quality, property size, energy performance, local environment, and services so your shortlist reflects how you actually want to live.':
'预算本身很少很重要。 Perfect Postcode 将价格过滤器与旅行时间、学校质量、房产规模、能源性能、当地环境和服务结合起来,因此您的候选名单反映了您真正想要的生活方式。',
'预算本身往往无法单独决定一切。Perfect Postcode 将价格筛选条件与出行时间、学校质量、房屋面积、能源性能、当地环境和服务结合起来,让您的候选名单真正反映您想要的生活方式。',
'What the price data is for': '价格数据的用途',
'Use the map to compare areas and spot search candidates. It isnt a valuation, mortgage decision, survey, legal search, or live listing feed.':
'使用地图比较区域并找到搜索候选者。它不是评估、抵押贷款决策、调查、法律搜索或实时列表源。',
'How to validate a promising area': '如何验证有前景的领域',
'使用地图比较区域并找到搜索候选者。它不是评估、抵押贷款决策、调查、法律调查或实时房源数据。',
'How to validate a promising area': '如何验证有潜力的区域',
'Once a postcode looks promising, check current listings, sold-price comparables, agent details, flood searches, legal packs, surveys, and local authority information before making a decision.':
'一旦邮政编码看起来很有前途,请在做出决定之前检查当前列表、可比售价、代理商详细信息、洪水搜索、法律包、调查和地方当局信息。',
'一旦某个邮编看起来有潜力,请在做出决定前查看当前房源、可比成交价、中介详情、洪水风险查询、法律资料包、验房报告和地方政府信息。',
'Is this a replacement for Rightmove or Zoopla?': '这是 Rightmove 或 Zoopla 的替代品吗?',
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show whats currently for sale.':
'不可以。在房源平台之前和旁边使用它。Perfect Postcode有助于决定去哪里查找房源平台显示当前正在出售的商品。',
'不是。请在使用房源平台之前及与之配合使用。Perfect Postcode 帮您决定到哪里去看,房源平台展示当前在售的房屋。',
'Can I compare price with schools or commute time?':
'我可以将价格与学校或通勤时间进行比较吗?',
'Yes. Price filters can be combined with travel-time, schools, crime, broadband, road-noise, amenities, and environment filters.':
'是的。价格过滤器可以与旅行时间、学校、犯罪、宽带、道路噪音、便利设施和环境过滤器结合起来。',
'是的。价格筛选条件可以与出行时间、学校、犯罪、宽带、道路噪音、便利设施和环境筛选条件结合起来。',
'Does the map cover all of the UK?': '地图涵盖了整个英国吗?',
'The current product focuses on England because several core property and postcode datasets are England-specific.':
'当前产品主要针对英格兰,因为一些核心财产和邮政编码数据集是英格兰特定的。',
'当前产品主要面向英格兰,因为若干核心的房产和邮编数据集仅适用于英格兰。',
'Birmingham property search guide': '伯明翰房产搜索指南',
'A worked example for balancing price, commute, and family trade-offs.':
'平衡价格、通勤和家庭权衡的有效示例。',
'一个实际案例,演示如何在价格、通勤和家庭需求之间取得平衡。',
'Data sources and coverage': '数据来源及覆盖范围',
'See which datasets sit behind the postcode filters and where they have limits.':
'查看邮政编码过滤器后面的数据集以及它们的限制。',
'查看邮编筛选条件后面的数据集以及它们的限制。',
Methodology: '方法论',
'Understand how the map is intended to support shortlisting, not replace due diligence.':
'了解地图如何支持入围,而不是取代尽职调查。',
'Postcode checker': '邮政编码检查器',
'了解地图如何支持候选,而不是取代尽职调查。',
'Postcode checker': '邮检查器',
'Check one postcode before you spend time on a viewing.':
'在您花时间查看之前,请检查一个邮政编码。',
'在您花时间看房之前,请检查一个邮编。',
'Explore the property map': '探索房产地图',
'Postcode property search': '邮政编码属性搜索',
'Postcode property search': '邮编房产搜索',
'Find postcodes that match your property search criteria':
'查找符合您的房产搜索条件的邮政编码',
'查找符合您的房产搜索条件的邮',
'Postcode property search - Find areas that match your criteria':
'邮政编码属性搜索 - 查找符合您条件的区域',
'邮编房产搜索 - 查找符合您条件的区域',
'Search every postcode by budget, property type, floor area, tenure, commute, schools, crime, broadband, noise, parks and local amenities.':
'按预算、房产类型、建筑面积、保有权、通勤、学校、犯罪、宽带、噪音、公园和当地设施搜索每个邮政编码。',
'按预算、房产类型、建筑面积、产权、通勤、学校、犯罪、宽带、噪音、公园和当地设施搜索每个邮编。',
'Search every postcode by budget, property type, size, tenure, commute, schools, crime, broadband, noise, parks, and local amenities instead of checking areas one at a time.':
'按预算、房产类型、面积、保有权、通勤、学校、犯罪、宽带、噪音、公园和当地设施搜索每个邮政编码,而不是一次检查一个区域。',
'按预算、房产类型、面积、产权、通勤、学校、犯罪、宽带、噪音、公园和当地设施搜索每个邮编,而不是一次检查一个区域。',
'Filter England-wide postcode data from one map.':
'从一张地图中过滤英格兰范围内的邮政编码数据。',
'从一张地图中筛选英格兰范围内的邮编数据。',
'Shortlist unfamiliar areas with comparable evidence.':
'将具有可比证据的不熟悉领域列入候选名单。',
'Save and share search areas before booking viewings.': '在预订观看之前保存并共享搜索区域。',
'Turn a broad brief into postcode candidates': '将广泛的简介转变为候选邮政编码',
'基于可比的数据证据,把陌生区域纳入候选名单。',
'Save and share search areas before booking viewings.': '在预约看房之前保存并共享搜索区域。',
'Turn a broad brief into postcode candidates': '将广泛的简介转变为候选邮',
'Enter the practical constraints first: budget, property size, tenure, travel time, school needs, broadband, and tolerance for road noise or crime levels. The map removes places that fail those constraints and keeps the remaining options comparable.':
'首先输入实际限制:预算、房产规模、保有权、旅行时间、学校需求、宽带以及对道路噪音或犯罪水平的容忍度。该地图删除了不符合这些限制的地点,并保持其余选项的可比性。',
'首先输入实际限制:预算、房产面积、产权、出行时间、学校需求、宽带以及对道路噪音或犯罪水平的容忍度。该地图删除了不符合这些限制的地点,并保持其余选项的可比性。',
'Relax one constraint at a time': '一次放松一项限制',
'When the search becomes too narrow, loosen a single filter and watch which postcodes reappear. This makes compromise explicit instead of relying on guesswork.':
'当搜索范围变得太窄时,松开单个过滤器并观察哪些邮政编码重新出现。这使得妥协变得明确,而不是依赖猜测。',
'Turn vague areas into specific postcodes': '将模糊区域变成特定的邮政编码',
'当搜索范围变得太窄时,松开单个筛选条件并观察哪些邮编重新出现。这使得妥协变得明确,而不是依赖猜测。',
'Turn vague areas into specific postcodes': '将模糊区域变成特定的邮',
'Broad town or borough searches hide large differences between streets. Perfect Postcode helps you move from a general area to postcodes that satisfy your hard requirements.':
'广泛的城镇或行政区搜索隐藏了街道之间的巨大差异。Perfect Postcode可帮助您从一般区域转移到满足您硬要求的邮政编码。',
'Keep trade-offs visible': '保持权衡可见',
'广泛的城镇或行政区搜索隐藏了街道之间的巨大差异。Perfect Postcode可帮助您从一般区域转移到满足您硬要求的邮。',
'Keep trade-offs visible': '让权衡清晰可见',
'When there are too many or too few matches, adjust one constraint at a time and see exactly which postcodes reappear. That makes compromises explicit instead of relying on guesswork.':
'当匹配项太多或太少时,一次调整一个约束并准确查看哪些邮政编码重新出现。这使得妥协变得明确,而不是依赖猜测。',
'Why postcode-level comparison matters': '为什么邮政编码级别的比较很重要',
'当匹配项太多或太少时,一次调整一个约束并准确查看哪些邮重新出现。这使得妥协变得明确,而不是依赖猜测。',
'Why postcode-level comparison matters': '为什么邮级别的比较很重要',
'Two nearby postcodes can differ on schools, road noise, transport access, property mix, and price. Comparing at postcode level reduces the chance of treating a whole town as one uniform market.':
'附近的两个邮政编码在学校、道路噪音、交通便利、房产组合和价格方面可能有所不同。在邮政编码级别进行比较减少了将整个城镇视为一个统一市场的机会。',
'附近的两个邮编在学校、道路噪音、交通便利、房产构成和价格方面可能有所不同。在邮编级别进行比较减少了将整个城镇视为一个统一市场的机会。',
'How to use the results': '如何使用结果',
'Treat matching postcodes as a research queue: check live listings, visit streets, confirm schools and admissions, and review current official sources.':
'将匹配的邮政编码视为研究队列:检查实时列表、访问街道、确认学校和招生以及查看当前的官方来源。',
'Can I save a postcode property search?': '我可以保存邮政编码属性搜索吗?',
'将匹配的邮编视为待研究清单:查看实时房源、实地走访街道、确认学校和招生情况,并参考当前的官方来源。',
'Can I save a postcode property search?': '我可以保存邮编房产搜索吗?',
'Yes. Licensed users can save searches and return to them later. Saved searches are designed for shortlists and comparison notes.':
'是的。许可用户可以保存搜索并稍后返回。保存的搜索专为候选列表和比较注释而设计。',
'是的。已授权用户可以保存搜索并稍后返回。保存的搜索专为候选列表和比较注释而设计。',
'Can I search without knowing the area?': '我可以在不知道区域的情况下进行搜索吗?',
'Yes. The map is designed to surface unfamiliar areas that match practical constraints, not just places you already know.':
'是的。该地图旨在显示符合实际限制的不熟悉的区域,而不仅仅是您已经知道的地方。',
'Are the results live property listings?': '结果是实时房产列表吗?',
'Are the results live property listings?': '结果是实时房产房源吗?',
'No. The tool compares postcode data and historical/contextual property signals. You still need listing portals for current availability.':
'不会。该工具会比较邮政编码数据和历史/上下文属性信号。您仍然需要列出当前可用性的门户。',
'不是。该工具比较邮编数据以及历史和背景类的房产信号。当前可售情况仍需查看房源平台。',
'Manchester property search guide': '曼彻斯特房产搜索指南',
'A regional guide for narrowing a broad search around Greater Manchester.':
'用于缩小大曼彻斯特广泛搜索范围的区域指南。',
'Start a postcode search': '开始邮政编码搜索',
'Start a postcode search': '开始邮搜索',
'Commute property search': '通勤房产搜索',
'Search for places to live by commute time': '按通勤时间搜索居住地',
'Commute property search - Find places to live by travel time':
'通勤房产搜索 - 按行时间查找居住地',
'通勤房产搜索 - 按行时间查找居住地',
'Filter postcodes by commute time, then compare price, schools, safety, broadband, road noise, parks and property data on one map.':
'按通勤时间过滤邮政编码,然后在一张地图上比较价格、学校、安全、宽带、道路噪音、公园和房产数据。',
'按通勤时间筛选邮编,然后在一张地图上比较价格、学校、安全、宽带、道路噪音、公园和房产数据。',
'Filter postcodes by modelled car, cycling, walking, and public transport travel times, then layer on property price, schools, crime, broadband, noise, and local amenities.':
'按模型汽车、自行车、步行和公共交通出行时间过滤邮政编码,然后按房价、学校、犯罪、宽带、噪音和当地便利设施进行分层。',
'按模型汽车、自行车、步行和公共交通出行时间筛选邮编,然后按房价、学校、犯罪、宽带、噪音和当地便利设施进行分层。',
'Compare reachable postcodes by realistic travel-time bands.':
'按实际旅行时间范围比较可到达的邮政编码。',
'按实际出行时间范围比较可到达的邮编。',
'Search by destination first, then filter for property and neighbourhood fit.':
'首先按目的地搜索,然后筛选适合的房产和社区。',
'Avoid areas that look close on a map but fail the daily journey.':
'避开那些在地图上看起来很接近但日常行程却失败的区域。',
'Start with the destination that matters': '从重要的目的地开始',
'Choose a commute destination, transport mode, and time range, then add the property filters. This prevents a cheap-looking area from reaching the shortlist if the daily journey doesnt work.':
'选择通勤目的地、交通方式和时间范围,然后添加属性过滤器。如果日常行程不起作用,这可以防止看似廉价的地区进入候选名单。',
'选择通勤目的地、交通方式和时间范围,然后添加房产筛选条件。如果日常行程不起作用,这可以防止看似廉价的地区进入候选名单。',
'Compare the commute against the rest of daily life': '将通勤与日常生活的其他部分进行比较',
'A fast commute isnt enough if the property size, school context, safety threshold, broadband, or road-noise exposure dont fit. The map keeps those signals side by side.':
'如果房产规模、学校环境、安全阈值、宽带或道路噪音暴露不合适,快速通勤是不够的。地图将这些信号并排保存。',
'Commute from postcodes, not just place names': '从邮政编码通勤,而不仅仅是地名',
'如果房产面积、学校环境、安全阈值、宽带或道路噪音暴露不合适,快速通勤是不够的。地图将这些信号并排保存。',
'Commute from postcodes, not just place names': '从邮通勤,而不仅仅是地名',
'Two streets in the same town can have very different station access, road routes, and public transport options. Postcode-level travel-time filtering keeps that difference visible.':
'同一城镇的两条街道可能有截然不同的车站通道、道路路线和公共交通选择。邮政编码级别的旅行时间过滤使这种差异可见。',
'同一城镇的两条街道可能有截然不同的车站通道、道路路线和公共交通选择。邮编级别的出行时间筛选使这种差异可见。',
'Balance journey time with the rest of the move': '平衡旅途时间与其余的搬家时间',
'A fast commute only helps if the area also fits your budget, housing needs, school preferences, safety threshold, broadband requirement, and tolerance for road noise.':
'只有当该地区也符合您的预算、住房需求、学校偏好、安全阈值、宽带要求和道路噪音容忍度时,快速通勤才有帮助。',
'How travel-time filters should be interpreted': '应如何解释旅行时间过滤器',
'How travel-time filters should be interpreted': '应如何解释出行时间筛选条件',
'Travel-time modelling is useful for comparing areas consistently. Before committing, check current timetables, disruption patterns, parking, cycling conditions, and walking routes.':
'时间建模对于一致地比较区域很有用。在做出决定之前,请检查当前的时间表、中断模式、停车、骑行条件和步行路线。',
'Why commute filters are combined with property data': '为什么通勤过滤器与房产数据相结合',
'行时间建模对于一致地比较区域很有用。在做出决定之前,请检查当前的时间表、中断模式、停车、骑行条件和步行路线。',
'Why commute filters are combined with property data': '为什么通勤筛选条件与房产数据相结合',
'Commute search is most useful when it removes impossible areas while still showing whether the remaining options are affordable and liveable.':
'当通勤搜索删除不可能的区域,同时仍显示剩余选项是否负担得起且宜居时,它是最有用的。',
'Can I compare car, cycling, walking, and public transport?':
@ -243,281 +244,281 @@ const zh: Translations = {
'该产品支持多种出行模式,其中预先计算的目的地数据可用。',
'Are travel times exact?': '出行时间准确吗?',
'No. Treat them as a consistent comparison model, then verify the real route before making viewing or purchase decisions.':
'不会。将它们视为一致的比较模型,然后在做出观看或购买决定之前验证真实路线。',
'不准确。请将它们视为一致的对比模型,然后在做出看房或购买决定之前验证真实路线。',
'Can I combine commute filters with schools and price?':
'我可以将通勤过滤器与学校和价格结合起来吗?',
'我可以将通勤筛选条件与学校和价格结合起来吗?',
'Yes. The commute filter can be layered with property price, size, schools, broadband, crime, amenities, and environmental signals.':
'是的。通勤过滤器可以根据房价、面积、学校、宽带、犯罪、便利设施和环境信号进行分层。',
'是的。通勤筛选条件可以根据房价、面积、学校、宽带、犯罪、便利设施和环境信号进行分层。',
'Bristol property search guide': '布里斯托尔房产搜索指南',
'A worked example for balancing city access, price, and local context.':
'平衡城市交通、价格和当地环境的有效示例。',
'一个实际案例,演示如何在进城便利、价格和当地环境之间取得平衡。',
'Search by commute time': '按通勤时间搜索',
'Schools and property search': '学校和房产搜索',
'Find property search areas with schools and family trade-offs in view':
'寻找考虑学校和家庭权衡的房产搜索区域',
'School property search - Compare postcodes for family moves':
'学校财产搜索 - 比较家庭搬迁的邮政编码',
'学校房产搜索 - 比较家庭搬迁的邮编',
'Compare nearby schools, property size, prices, parks, safety, commute and local amenities before building a viewing shortlist.':
'在建立看候选名单之前,比较附近的学校、房产规模、价格、公园、安全、通勤和当地便利设施。',
'在建立候选名单之前,比较附近的学校、房产面积、价格、公园、安全、通勤和当地便利设施。',
'Compare nearby Ofsted ratings, education context, property size, budget, safety, parks, commute, and local amenities before narrowing your viewing shortlist.':
'比较附近的 Ofsted 评级、教育背景、房产规模、预算、安全、公园、通勤和当地设施,然后再缩小您的看候选名单。',
'比较附近的 Ofsted 评级、教育背景、房产面积、预算、安全、公园、通勤和当地设施,然后再缩小您的看候选名单。',
'Filter for nearby school quality alongside housing requirements.':
'筛选附近学校的质量以及住房要求。',
'Compare family-friendly trade-offs across unfamiliar postcodes.':
'比较不熟悉的邮政编码中适合家庭的权衡。',
'比较不熟悉的邮中适合家庭的权衡。',
'Use the map as a shortlist tool before checking admissions and catchments.':
'在检查招生和流域之前,请使用地图作为候选名单工具。',
'Use school context without ignoring the home': '利用学校环境而不忽视家庭',
'Start with property size, budget, and commute constraints, then layer in nearby school quality and local context. This prevents school-led searches from hiding affordability or daily-life problems.':
'从房产规模、预算和通勤限制开始,然后分层考虑附近的学校质量和当地环境。这可以防止学校主导的搜索隐藏负担能力或日常生活问题。',
'从房产面积、预算和通勤限制开始,然后分层考虑附近的学校质量和当地环境。这可以防止学校主导的搜索隐藏负担能力或日常生活问题。',
'Verify admissions before deciding': '决定前核实录取情况',
'School data can point to promising areas, but admissions rules and catchments can change. Confirm current arrangements with schools and local authorities.':
'学校数据可能会指出有前途的领域,但招生规则和学区可能会发生变化。与学校和地方当局确认当前的安排。',
'School quality is one part of the shortlist': '学校质量是入围名单之一',
'学校数据可以指向有潜力的区域,但招生规则和学区可能发生变化。请与学校和地方政府确认当前的安排。',
'School quality is one part of the shortlist': '学校质量是候选名单之一',
'Perfect Postcode helps you compare nearby school data with the other practical constraints that shape a family move: space, price, commute, parks, safety, and local services.':
'Perfect Postcode 可帮助您将附近的学校数据与影响家庭搬家的其他实际限制因素进行比较:空间、价格、通勤、公园、安全和当地服务。',
'Check catchments before making decisions': '做出决定前检查流域',
'Admissions rules and catchment boundaries can change. Use postcode-level school data to find promising areas, then verify current admissions details with the school or local authority.':
'招生规则和学区边界可能会发生变化。使用邮政编码级别的学校数据来寻找有前途的地区,然后与学校或地方当局核实当前的招生详细信息。',
'How to treat school filters': '如何处理学校过滤器',
'招生规则和学区边界可能会发生变化。使用邮编级别的学校数据来寻找有潜力的地区,然后与学校或地方政府核实当前的招生详细信息。',
'How to treat school filters': '如何处理学校筛选条件',
'Use school filters to narrow research, not to assume admission eligibility. Ratings, distance, admissions criteria, and school capacity should all be checked with current official sources.':
'使用学校过滤器来缩小研究范围,而不是假设入学资格。评级、距离、录取标准和学校容量都应通过当前的官方来源进行检查。',
'使用学校筛选条件来缩小研究范围,而不是假设入学资格。评级、距离、录取标准和学校容量都应通过当前的官方来源进行检查。',
'Family trade-offs to compare': '家庭权衡比较',
'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.':
'将学校与公园、道路噪音、犯罪、房产规模、通勤、宽带和价格结合起来,这样入围名单就能反映整个搬迁情况。',
'将学校与公园、道路噪音、犯罪、房产面积、通勤、宽带和价格结合起来,这样候选名单就能反映整个搬迁情况。',
'Does this show school catchment guarantees?': '这是否表明学校学区有保证?',
'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.':
'不会。它有助于确定有前途的地区,但学区和招生必须经过学校或地方当局的核实。',
'不会。它有助于确定有潜力的地区,但学区和招生必须经过学校或地方政府的核实。',
'Can I combine school filters with parks and safety?':
'我可以将学校过滤器与公园和安全结合起来吗?',
'我可以将学校筛选条件与公园和安全结合起来吗?',
'Yes. School-aware search can be combined with crime, parks, commute, price, property size, and local services.':
'是的。学校感知搜索可以与犯罪、公园、通勤、价格、房产规模和本地服务相结合。',
'可以。结合学校的搜索可以与犯罪、公园、通勤、价格、房产面积和当地服务相组合。',
'Is Ofsted the only school signal?': 'Ofsted 是唯一的学校信号吗?',
'No single score should decide a move. Use the map as a starting point, then review current school information in detail.':
'任何一个分数都不能决定行动。使用地图作为起点,然后详细查看当前学校信息。',
'See where education, property, transport, and environment data comes from.':
'查看教育、房地产、交通和环境数据的来源。',
'Explore school-aware searches': '探索学校相关的搜索',
'Check postcode data before you book a viewing': '在预订观看之前检查邮政编码数据',
'Check postcode data before you book a viewing': '在预约看房之前检查邮编数据',
'Postcode checker - Property, crime, broadband, noise and schools':
'邮政编码检查器 - 财产、犯罪、宽带、噪音和学校',
'邮编检查器 - 房产、犯罪、宽带、噪音和学校',
'Check postcode-level property prices, EPC data, crime, broadband, road noise, schools, council tax, amenities and travel-time context.':
'检查邮政编码级别的房地产价格、EPC 数据、犯罪、宽带、道路噪音、学校、市政税、便利设施和行时间背景。',
'检查邮级别的房地产价格、EPC 数据、犯罪、宽带、道路噪音、学校、市政税、便利设施和行时间背景。',
'Review property prices, EPC context, crime, broadband, road noise, local amenities, schools, deprivation, council tax, and travel-time data from one postcode-first map.':
'从一张邮政编码优先的地图中查看房地产价格、EPC 背景、犯罪、宽带、道路噪音、当地便利设施、学校、贫困、市政税和行时间数据。',
'Check multiple local signals before visiting a street.': '在访问街道之前检查多个当地信号。',
'从一张邮优先的地图中查看房地产价格、EPC 背景、犯罪、宽带、道路噪音、当地便利设施、学校、贫困、市政税和行时间数据。',
'Check multiple local signals before visiting a street.': '在实地走访街道之前查看多项当地信息指标。',
'Use official and open datasets rather than reputation alone.':
'使用官方和开放的数据集,而不仅仅是声誉。',
'Compare postcodes consistently across England.': '一致比较英格兰各地的邮政编码。',
'Check the street before spending a viewing slot': '在花费观看时间之前检查一下街道',
'Compare postcodes consistently across England.': '一致比较英格兰各地的邮。',
'Check the street before spending a viewing slot': '在花时间看房之前检查一下街道',
'Use the postcode checker to review price history, local context, amenities, schools, and environment signals before you commit time to visiting.':
'在您花时间参观之前,使用邮政编码检查器查看价格历史记录、当地背景、便利设施、学校和环境信号。',
'Compare neighbouring postcodes': '比较邻近的邮政编码',
'在您花时间看房之前,使用邮编检查器查看价格历史记录、当地背景、便利设施、学校和环境信号。',
'Compare neighbouring postcodes': '比较邻近的邮',
'If one postcode looks promising, compare adjacent areas using the same filters. This often reveals whether a concern is street-specific or part of a wider pattern.':
'如果一个邮政编码看起来很有希望,请使用相同的过滤器比较相邻区域。这通常可以揭示问题是特定于街道的还是更广泛模式的一部分。',
'Useful before and alongside listing portals': '在房源平台之前和旁边有用',
'如果一个邮编看起来很有希望,请使用相同的筛选条件比较相邻区域。这通常可以揭示问题是特定于街道的还是更广泛模式的一部分。',
'Useful before and alongside listing portals': '在使用房源平台之前及与之配合使用都很有用',
'Listing photos rarely tell you enough about the surrounding street. Perfect Postcode gives you an evidence-led postcode check before you commit time to a viewing.':
'房源照片很少能告诉您有关周围街道的足够信息。Perfect Postcode在您投入时间观看之前为您提供以证据为主导的邮政编码检查。',
'房源照片很少能告诉您有关周围街道的足够信息。Perfect Postcode在您投入时间看房之前为您提供以证据为主导的邮编检查。',
'A screening tool, not professional advice': '筛查工具,而非专业建议',
'The data is designed for shortlisting and comparison. Any purchase still needs current listing checks, legal due diligence, flood searches, lender requirements, and survey findings.':
'该数据旨在用于筛选和比较。任何购买仍需要当前的清单检查、法律尽职调查、大量搜索、贷方要求和调查结果。',
'What a postcode check can catch': '邮政编码检查可以发现什么',
'该数据旨在用于筛选和比较。任何购买仍需要当前的清单检查、法律尽职调查、大量搜索、贷方要求和验房结果。',
'What a postcode check can catch': '邮检查可以发现什么',
'A postcode check can surface price context, environmental signals, nearby amenities, and other local indicators that are easy to miss in a listing.':
'邮政编码检查可以显示价格背景、环境信号、附近的便利设施以及列表中容易错过的其他本地指标。',
'What a postcode check cant prove': '邮政编码检查无法证明什么',
'邮检查可以显示价格背景、环境信号、附近的便利设施以及列表中容易错过的其他本地指标。',
'What a postcode check cant prove': '邮检查无法证明什么',
'It cant confirm the condition of a home, future development, legal title, lender requirements, or current street-level experience. Those still need direct checks.':
'它无法确认房屋的状况、未来的开发、法定所有权、贷款人要求或当前的街道经验。这些仍然需要直接检查。',
'Can I use the checker before a viewing?': '我可以在看前使用检查器吗?',
'Can I use the checker before a viewing?': '我可以在前使用检查器吗?',
'Yes. Thats one of the main use cases: screen the postcode first, then decide whether the viewing is worth the time.':
'是的。这是主要用例之一:首先筛选邮政编码,然后决定是否值得花时间查看。',
'是的。这是主要用例之一:首先筛选邮,然后决定是否值得花时间查看。',
'Does the checker include exact property condition?': '检查器是否包括准确的财产状况?',
'No. Property condition requires listing details, surveys, and direct inspection.':
'不可以。房产状况需要列出详细信息、调查和直接检查。',
'Can I compare multiple postcodes?': '我可以比较多个邮政编码吗?',
'Can I compare multiple postcodes?': '我可以比较多个邮吗?',
'Yes. The map is designed for consistent comparison across postcodes.':
'是的。该地图旨在实现跨邮政编码的一致比较。',
'Check postcodes on the map': '检查地图上的邮政编码',
'是的。该地图旨在实现跨邮的一致比较。',
'Check postcodes on the map': '检查地图上的邮',
'Regional guide': '区域指南',
'How to compare Birmingham postcodes before a property search':
'如何在房产搜索前比较伯明翰邮政编码',
'如何在房产搜索前比较伯明翰邮',
'Birmingham property search - Compare postcodes by price and commute':
'伯明翰房产搜索 - 按价格和通勤比较邮政编码',
'伯明翰房产搜索 - 按价格和通勤比较邮',
'Use postcode-level data to compare Birmingham property prices, commute trade-offs, schools, crime, broadband and local amenities before viewings.':
'在查看之前,使用邮政编码级别的数据来比较伯明翰的房价、通勤权衡、学校、犯罪、宽带和当地设施。',
'在查看之前,使用邮级别的数据来比较伯明翰的房价、通勤权衡、学校、犯罪、宽带和当地设施。',
'Birmingham searches can change quickly from street to street. Use postcode-level evidence to compare budget, commute, schools, noise, crime, and local services before deciding where to watch listings.':
'伯明翰的搜索可能因街道而异。在决定在哪里观看列表之前,使用邮政编码级别的证据来比较预算、通勤、学校、噪音、犯罪和当地服务。',
'伯明翰的搜索可能因街道而异。在决定关注哪里的房源之前,使用邮编级别的证据来比较预算、通勤、学校、噪音、犯罪和当地服务。',
'Start with commute corridors': '从通勤走廊开始',
'Choose the destination that matters, such as a workplace, station, university, or hospital, then compare reachable postcodes by transport mode and travel-time band.':
'选择重要的目的地,例如工作场所、车站、大学或医院,然后按交通方式和旅行时间范围比较可到达的邮政编码。',
'选择重要的目的地,例如工作场所、车站、大学或医院,然后按交通方式和出行时间范围比较可到达的邮编。',
'Use commute time as a hard filter before judging price.':
'在判断价格之前,使用通勤时间作为硬过滤器。',
'在判断价格之前,使用通勤时间作为硬筛选条件。',
'Compare public transport with car, cycling, or walking where available.':
'将公共交通与汽车、骑自行车或步行(如果有)进行比较。',
'Check the route manually before booking viewings.': '在预订观看之前手动检查路线。',
'Check the route manually before booking viewings.': '在预约看房之前手动检查路线。',
'Compare price with property type': '将价格与房产类型进行比较',
'Median prices alone can be misleading if the local property mix changes. Add property type, tenure, floor area, and price filters so similar areas are compared fairly.':
'如果当地房地产结构发生变化,中位价格可能会产生误导。添加房产类型、保有权、建筑面积和价格过滤器,以便公平比较相似的区域。',
'如果当地房地产结构发生变化,中位价格可能会产生误导。添加房产类型、产权、建筑面积和价格筛选条件,以便公平比较相似的区域。',
'Keep family and environment trade-offs visible': '让家庭和环境之间的权衡显而易见',
'Layer school context, parks, road noise, broadband, and crime signals on top of the property filters. That makes it easier to decide which compromises are acceptable.':
'将学校环境、公园、道路噪音、宽带和犯罪信号叠加在属性过滤器之上。这使得更容易决定哪些妥协是可以接受的。',
'将学校环境、公园、道路噪音、宽带和犯罪信号叠加在房产筛选条件之上。这使得更容易决定哪些妥协是可以接受的。',
'Can Perfect Postcode tell me the best area in Birmingham?':
'Perfect Postcode可以告诉我 伯明翰 最好的区域吗?',
'No tool can decide the best area for every buyer. It helps compare postcodes against your own constraints so you can build a better shortlist.':
'没有任何工具可以为每个买家决定最佳区域。它有助于将邮政编码与您自己的限制进行比较,以便您可以构建更好的候选名单。',
'没有任何工具可以为每个买家决定最佳区域。它有助于将邮与您自己的限制进行比较,以便您可以构建更好的候选名单。',
'Should I use this instead of local knowledge?': '我应该使用这个而不是本地知识吗?',
'No. Use it to find and compare candidates, then validate them with visits, local advice, listings, and official checks.':
'不会。用它来查找和比较候选人,然后通过访问、当地建议、列表和官方检查来验证他们。',
'不是。用它来寻找和比较候选区域,然后通过实地走访、当地建议、房源信息和官方核查加以验证。',
'Compare price patterns before looking at live listings.':
'在查看实时列表之前先比较价格模式。',
'在查看实时房源之前先比较价格模式。',
'Search by travel time and then layer on property requirements.':
'按行时间搜索,然后按财产要求分层。',
'Understand how to interpret filters and limitations.': '了解如何解释过滤器和限制。',
'Compare Birmingham postcodes': '比较伯明翰 邮政编码',
'按行时间搜索,然后按财产要求分层。',
'Understand how to interpret filters and limitations.': '了解如何解释筛选条件和限制。',
'Compare Birmingham postcodes': '比较伯明翰 邮',
'How to compare Manchester postcodes for a property search':
'如何比较曼彻斯特邮政编码以进行房产搜索',
'如何比较曼彻斯特邮以进行房产搜索',
'Manchester property search - Compare postcodes before viewing':
'曼彻斯特房产搜索 - 查看前比较邮政编码',
'曼彻斯特房产搜索 - 看房前比较邮编',
'Compare Manchester-area postcodes by budget, commute, property type, schools, broadband, crime, noise and amenities before booking viewings.':
'在预订观看之前,请按预算、通勤、房产类型、学校、宽带、犯罪、噪音和便利设施比较曼彻斯特地区的邮政编码。',
'在预约看房之前,请按预算、通勤、房产类型、学校、宽带、犯罪、噪音和便利设施比较曼彻斯特地区的邮。',
'A Manchester-area search can span city-centre, suburban, and commuter options. Perfect Postcode helps keep each postcode comparable against the same property and daily-life constraints.':
'曼彻斯特地区的搜索可以涵盖市中心、郊区和通勤选项。Perfect Postcode有助于使每个邮政编码在相同的财产和日常生活限制下具有可比性。',
'Use travel time to define the real search area': '使用时间来定义真正的搜索区域',
'曼彻斯特地区的搜索可以涵盖市中心、郊区和通勤选项。Perfect Postcode有助于使每个邮在相同的财产和日常生活限制下具有可比性。',
'Use travel time to define the real search area': '使用行时间来定义真正的搜索区域',
'Start from the destinations that matter, then compare reachable postcodes rather than assuming every nearby place has the same practical journey.':
'从重要的目的地开始,然后比较可到达的邮政编码,而不是假设附近的每个地方都有相同的实际旅程。',
'从重要的目的地开始,然后比较可到达的邮,而不是假设附近的每个地方都有相同的实际旅程。',
'Compare housing requirements before lifestyle preferences': '在生活方式偏好之前比较住房要求',
'Filter by property type, floor area, tenure, and price before judging amenities. That keeps the shortlist grounded in homes that could realistically work.':
'在判断便利设施之前,请按房产类型、建筑面积、使用期限和价格进行筛选。这使得入围名单以能够实际使用的房屋为基础。',
'Check local context consistently': '一致地检查本地上下文',
'在评判便利设施之前,先按房产类型、建筑面积、产权和价格进行筛选。这能让候选名单基于切实可行的房源。',
'Check local context consistently': '一致地查看当地背景',
'Use broadband, crime, road noise, parks, schools, and amenities as comparable signals. Then validate the strongest candidates with current local checks.':
'使用宽带、犯罪、道路噪音、公园、学校和便利设施作为可比信号。然后通过当前的本地检查来验证最强的候选人。',
'将宽带、犯罪、道路噪音、公园、学校和便利设施作为可比信号。然后对最有潜力的候选区域通过当前的当地查证加以核实。',
'Can I compare Manchester suburbs with city-centre postcodes?':
'我可以将曼彻斯特郊区与市中心的邮政编码进行比较吗?',
'我可以将曼彻斯特郊区与市中心的邮进行比较吗?',
'Yes. Use the same budget, property, commute, and local-context filters across both so trade-offs remain visible.':
'是的。在两者中使用相同的预算、财产、通勤和当地环境过滤器,以便权衡仍然可见。',
'Does this include live listings?': '这包括实时列表吗?',
'是的。在两者中使用相同的预算、房产、通勤和当地环境筛选条件,以便权衡仍然可见。',
'Does this include live listings?': '这包括实时房源吗?',
'No. Use it to decide where to search, then use listing portals for current homes for sale.':
'不会。用它来决定在哪里搜索,然后使用当前待售房屋的房源平台。',
'Move from a broad search brief to specific postcode candidates.':
'从广泛的搜索概要转向特定的邮政编码候选人。',
'从宽泛的搜索意向转向具体的候选邮编。',
'Data sources': '数据来源',
'Review the datasets used for property and local-context comparison.':
'查看用于属性和本地上下文比较的数据集。',
'Check a single postcode before arranging a viewing.': '在安排观看之前检查单个邮政编码。',
'Compare Manchester postcodes': '比较曼彻斯特邮政编码',
'查看用于房产和当地背景比较的数据集。',
'Check a single postcode before arranging a viewing.': '在安排看房之前检查单个邮编。',
'Compare Manchester postcodes': '比较曼彻斯特邮',
'How to compare Bristol postcodes before a property search':
'如何在房产搜索前比较布里斯托尔邮政编码',
'如何在房产搜索前比较布里斯托尔邮',
'Bristol property search - Compare postcodes by commute and price':
'布里斯托尔房产搜索 - 按通勤和价格比较邮政编码',
'布里斯托尔房产搜索 - 按通勤和价格比较邮',
'Compare Bristol postcodes by price, commute, property size, schools, broadband, crime, road noise, parks and amenities before viewings.':
'在查看之前,按价格、通勤、房产规模、学校、宽带、犯罪、道路噪音、公园和便利设施比较布里斯托尔邮政编码。',
'在查看之前,按价格、通勤、房产面积、学校、宽带、犯罪、道路噪音、公园和便利设施比较布里斯托尔邮编。',
'Bristol searches often involve sharp trade-offs between price, journey time, property size, and neighbourhood context. A postcode-first comparison keeps those trade-offs visible.':
'布里斯托尔的搜索通常涉及价格、行程时间、房产规模和社区环境之间的尖锐权衡。邮政编码优先的比较使这些权衡显而易见。',
'布里斯托尔的搜索通常涉及价格、出行时间、房产面积和社区环境之间的尖锐权衡。邮编优先的比较使这些权衡显而易见。',
'Make commute constraints explicit': '明确通勤限制',
'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.':
'如果前往中心、车站、医院、大学或商业园区很重要,请首先使用出行时间过滤器,然后通过属性数据比较剩余的邮政编码。',
'如果前往中心、车站、医院、大学或商业园区很重要,请首先使用出行时间筛选条件,然后通过房产数据比较剩余的邮编。',
'Compare value, not just headline price': '比较价值,而不仅仅是标题价格',
'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.':
'将价格、房产类型和建筑面积过滤器结合使用。这有助于将低成本区域与仅包含较小或不同房屋的区域区分开来。',
'将价格、房产类型和建筑面积筛选条件结合使用。这有助于将低成本区域与仅包含较小或不同房屋的区域区分开来。',
'Screen environmental and local-service signals': '筛选环境和本地服务信号',
'Road noise, parks, broadband, crime, and amenities can affect whether a property works day to day. Use them as screening criteria before booking viewings.':
'道路噪音、公园、宽带、犯罪和便利设施都会影响房产的日常运作。在预订观看之前将它们用作筛选标准。',
'道路噪音、公园、宽带、犯罪和便利设施都会影响房产的日常运作。在预约看房之前将它们用作筛选标准。',
'Can I use this for commuter villages around Bristol?':
'我可以将其用于布里斯托尔周围的通勤村庄吗?',
'Yes, where the relevant postcode and travel-time data is available. Always verify routes and services manually before deciding.':
'是的,只要有相关邮政编码和旅行时间数据即可。在做出决定之前,请务必手动验证路线和服务。',
'是的,只要有相关邮编和出行时间数据即可。在做出决定之前,请务必手动验证路线和服务。',
'Can this tell me whether a listing is good value?': '这可以告诉我列表是否物有所值吗?',
'It can provide area context, but a specific listing still needs comparable sales, condition checks, survey findings, and professional advice where appropriate.':
'它可以提供区域背景,但特定列表仍然需要可比较的销售、状况检查、调查结果和适当的专业建议。',
'它可以提供区域背景,但特定列表仍然需要可比较的销售、状况检查、验房结果和适当的专业建议。',
'Search by reachable postcodes before refining by budget and local context.':
'先按可达的邮政编码进行搜索,然后再按预算和当地情况进行细化。',
'先按可达的邮进行搜索,然后再按预算和当地情况进行细化。',
'Understand price patterns before setting listing alerts.':
'在设置列表提醒之前了解价格模式。',
'在设置房源提醒之前了解价格模式。',
'Privacy and security': '隐私和安全',
'How account and saved-search data is handled in the product.':
'产品中如何处理帐户和已保存的搜索数据。',
'Compare Bristol postcodes': '比较布里斯托尔邮政编码',
'Trust and coverage': '信任和覆盖',
'Perfect Postcode data sources and coverage': 'Perfect Postcode数据源和覆盖范围',
'Compare Bristol postcodes': '比较布里斯托尔邮',
'Trust and coverage': '可信度与覆盖范围',
'Perfect Postcode data sources and coverage': 'Perfect Postcode 数据源和覆盖范围',
'Perfect Postcode data sources - Property, schools, commute and local context':
'完美的邮政编码数据源 - 房产、学校、通勤和当地情况',
'Perfect Postcode 数据来源 - 房产、学校、通勤和当地背景',
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.':
'查看 Perfect Postcode 使用的公共和官方数据集包括房价、EPC、学校、犯罪、宽带、噪音和行时间背景。',
'查看 Perfect Postcode 使用的公共和官方数据集包括房价、EPC、学校、犯罪、宽带、噪音和行时间背景。',
'Perfect Postcode combines property, transport, education, environment, and local-service datasets so buyers can compare postcodes consistently. This page explains what the data is for and where it should be verified.':
'Perfect Postcode 结合了房地产、交通、教育、环境和本地服务数据集,因此买家可以一致地比较邮政编码。此页面解释了数据的用途以及应在何处验证数据。',
'Property and housing context': '财产和住房环境',
'Perfect Postcode 结合了房地产、交通、教育、环境和本地服务数据集,因此买家可以一致地比较邮。此页面解释了数据的用途以及应在何处验证数据。',
'Property and housing context': '房产和住房背景',
'The product uses property transaction and housing-context datasets to support filters such as sale price, property type, tenure, floor area, energy performance, and estimated current value.':
'该产品使用房产交易和住房背景数据集来支持销售价格、房产类型、保有权、建筑面积、能源绩效和估计当前价值等过滤器。',
'该产品使用房产交易和住房背景数据集来支持销售价格、房产类型、产权、建筑面积、能源绩效和估计当前价值等筛选条件。',
'Use these fields to compare areas, not as a formal valuation.':
'使用这些字段来比较面积,而不是作为正式评估。',
'使用这些字段来比较区域,而不是作为正式估值。',
'Check current listings, title information, lender requirements, and survey results before buying.':
'购买前检查当前列表、产权信息、贷方要求和调查结果。',
'购买前检查当前房源、产权信息、贷方要求和验房结果。',
'Schools, safety, broadband, and environment': '学校、安全、宽带和环境',
'Local-context filters help compare postcodes on signals that affect daily life. They should be treated as screening data and checked against current official sources for decisions.':
'本地上下文过滤器有助于比较影响日常生活的信号的邮政编码。它们应被视为筛选数据,并根据当前的官方来源进行检查以做出决定。',
'Travel-time data': '时间数据',
'当地背景筛选条件有助于按影响日常生活的指标比较邮编。它们应被视为初筛数据,决策时需参照当前的官方来源核实。',
'Travel-time data': '行时间数据',
'Travel-time filters are designed for consistent area comparison. Route availability, disruption, parking, walking access, and timetable details should be verified before committing to an area.':
'传播时间过滤器旨在实现一致的面积比较。在前往某个区域之前,应验证路线可用性、中断情况、停车位、步行通道和时间表详细信息。',
'Why does coverage focus on England?': '为什么报道聚焦英格兰?',
'出行时间筛选条件旨在实现一致的区域比较。在选定某个区域之前,应验证路线可用性、中断情况、停车位、步行通道和时刻表详情。',
'Why does coverage focus on England?': '为什么覆盖范围聚焦于英格兰?',
'Several core property, education, and local-context datasets are jurisdiction-specific. England coverage keeps comparisons more consistent.':
'一些核心财产、教育和当地背景数据集是特定于管辖区的。英格兰的报道使比较更加一致。',
'若干核心的房产、教育和当地背景数据集仅适用于特定司法辖区。聚焦英格兰能让比较保持更一致。',
'How should I handle stale or missing data?': '我应该如何处理陈旧或丢失的数据?',
'Use the map as a shortlist tool. If a postcode matters, verify the latest details with current official sources and direct local checks.':
'使用地图作为候选名单工具。如果邮政编码很重要,请通过当前的官方来源验证最新详细信息并直接进行本地检查。',
'How filters and comparisons should be interpreted.': '应如何解释过滤器和比较。',
'Review postcode-level context before a viewing.': '在查看之前查看邮政编码级别的上下文。',
'将地图当作候选名单工具。如果某个邮编很关键,请通过当前的官方来源核实最新信息,并直接进行当地查证。',
'How filters and comparisons should be interpreted.': '应如何理解筛选条件和比较结果。',
'Review postcode-level context before a viewing.': '在看房前查看邮编层级的背景信息。',
'How saved searches and account data are handled.': '如何处理保存的搜索和帐户数据。',
'How to use the map': '如何使用地图',
'Methodology for postcode property research': '邮政编码财产研究方法',
'Methodology for postcode property research': '邮编房产研究方法论',
'Perfect Postcode methodology - How to interpret postcode property data':
'Perfect Postcode方法 - 如何解释邮政编码属性数据',
'Perfect Postcode 方法论 - 如何理解邮编房产数据',
'Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.':
'了解如何使用邮政编码过滤器、房产估算、旅行时间数据、学校背景和当地信号作为购房候选名单工具。',
'了解如何使用邮编筛选条件、房产估算、出行时间数据、学校背景和当地信号作为购房候选名单工具。',
'Perfect Postcode is designed to make area shortlisting more evidence-led. It doesnt replace estate agents, surveyors, conveyancers, lenders, school admissions teams, or local authority checks.':
'Perfect Postcode 旨在使区域入围更加以证据为主导。它不能取代房地产经纪人、测量师、产权转让师、贷款人、学校招生团队或地方当局的检查。',
'Perfect Postcode 旨在让区域候选更以证据为导向。它不能取代房地产中介、测量师、产权转让律师、贷款机构、学校招生部门或地方政府的核查。',
'Start with hard constraints': '从硬性约束开始',
'Begin with non-negotiables such as budget, property type, floor area, commute time, and essential services. This removes impossible postcodes before softer preferences are considered.':
'从预算、房产类型、建筑面积、通勤时间和基本服务等不可协商的事项开始。这会在考虑较软的偏好之前删除不可能的邮政编码。',
'从预算、房产类型、建筑面积、通勤时间和基本服务等不可妥协的条件入手。在考虑次要偏好之前,先排除不可能的邮编。',
'Use colour layers for trade-offs': '使用颜色层进行权衡',
'After filtering, colour the remaining map by one signal at a time: price per square metre, road noise, school context, commute time, broadband, or crime. This makes trade-offs easier to discuss.':
'过滤后,一次按一个信号对剩余地图进行着色:每平方米价格、道路噪音、学校环境、通勤时间、宽带或犯罪情况。这使得权衡更容易讨论。',
'筛选后,一次按一个信号对剩余地图进行着色:每平方米价格、道路噪音、学校环境、通勤时间、宽带或犯罪情况。这使得权衡更容易讨论。',
'Measure whats working': '衡量哪些内容有效',
'Use Search Console and analytics to track which public pages are indexed, which queries produce impressions, and which pages convert visitors into dashboard exploration. Review Core Web Vitals after every substantial frontend change.':
'使用 Search Console 和分析来跟踪哪些公共页面被索引、哪些查询产生展示次数以及哪些页面将访问者转化为仪表板探索。在每次重大前端更改后查看 Core Web Vitals。',
'Can the tool choose the right postcode for me?': '该工具可以为我选择正确的邮政编码吗?',
'Can the tool choose the right postcode for me?': '该工具可以为我选择正确的邮吗?',
'No. It helps compare evidence and reduce the search area. The final decision needs direct visits, current listings, legal checks, surveys, and personal judgement.':
'不会。它有助于比较证据并缩小搜索范围。最终决定需要直接访问、当前列表、法律检查、调查和个人判断。',
'不能。它能帮您比较证据并缩小搜索范围。最终决定还需要实地走访、当前房源、法律核查、验房和个人判断。',
'How should I use estimates?': '我应该如何使用估算值?',
'Use estimates as comparison signals, not as professional valuations or purchase advice.':
'使用估算作为比较信号,而不是作为专业估价或购买建议。',
'Understand where key filters come from.': '了解关键过滤器的来源。',
'将估算值作为比较参考,而不是专业估价或购房建议。',
'Understand where key filters come from.': '了解关键筛选条件的来源。',
'Apply the methodology to price-led area comparison.': '将该方法应用于价格主导的区域比较。',
'Apply the methodology to destination-led search.': '将该方法应用于以目的地为主导的搜索。',
Trust: '信任',
'Privacy and security for saved property searches': '保存的财产搜索的隐私和安全',
'Privacy and security for saved property searches': '已保存房产搜索的隐私与安全',
'Perfect Postcode privacy and security - Saved searches and account data':
'完美的邮政编码隐私和安全 - 保存的搜索和帐户数据',
'Perfect Postcode 隐私与安全 - 已保存的搜索和账户数据',
'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.':
'了解 Perfect Postcode 如何在考虑隐私和安全的情况下处理已保存的搜索、帐户数据和产研究工作流程。',
'了解 Perfect Postcode 如何在考虑隐私和安全的情况下处理已保存的搜索、帐户数据和产研究工作流程。',
'Property research can reveal personal priorities, budgets, and locations. The product keeps public SEO pages separate from account-only areas and marks private dashboard/account routes as noindex.':
'房地产研究可以揭示个人优先事项、预算和地点。该产品将公共 SEO 页面与仅帐户区域分开,并将私人仪表板/帐户路线标记为 noindex。',
'房产研究可能暴露个人偏好、预算和地点。本产品将公共 SEO 页面与仅限账户访问的区域分开,并将私人面板/账户路径标记为 noindex禁止索引。',
'Public pages and private areas are separated': '公共页面和私人区域分开',
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
'营销、方法、指南和支持页面都是可索引的。仪表板、帐户、已保存的搜索、邀请和邀请路线被标记为 noindex 或在适当的情况下阻止爬网程序访问。',
'Saved search data is account-scoped': '保存的搜索数据是帐户范围内的',
'营销、方法论、指南和支持页面是可被搜索引擎索引的。面板、账户、已保存搜索、邀请和邀请页面则被标记为 noindex或在适当情况下被阻止爬虫访问。',
'Saved search data is account-scoped': '已保存的搜索数据仅限账户范围',
'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'保存的搜索和共享链接仅供登录使用。它们不包含在公共站点地图中,也不应作为公共内容进行抓取。',
'Search measurement without exposing private data': '搜索测量而不暴露私人数据',
'已保存的搜索和分享链接仅供已登录用户使用。它们不包含在公共站点地图中,也不应作为公共内容被爬取。',
'Search measurement without exposing private data': '在不暴露私人数据的前提下衡量搜索效果',
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldnt become indexable landing pages.':
'SEO 测量应该使用聚合分析和 Search Console 数据在公共页面上进行。私有查询参数和帐户视图不应成为可索引的登陆页面。',
'SEO 衡量应基于公共页面,使用聚合分析和 Search Console 数据完成。私有查询参数和账户视图不应成为可被索引的落地页。',
'Are saved searches listed in the sitemap?': '站点地图中是否列出了已保存的搜索?',
'No. Public SEO pages are listed; account and saved-search routes are intentionally excluded.':
'否。列出了公共 SEO 页面;帐户和保存的搜索路线被有意排除。',
'Can private dashboard URLs appear in search?': '私有仪表板 URL 可以出现在搜索中吗?',
'不会。仅列出公共 SEO 页面;账户和已保存搜索的路径被有意排除。',
'Can private dashboard URLs appear in search?': '私人面板的 URL 会出现在搜索结果中吗?',
'They shouldnt be indexed. The server marks private routes noindex and the sitemap only lists public pages.':
'它们不应该被索引。服务器将私有路由标记为noindex站点地图仅列出公共页面。',
'How to use public postcode data responsibly.': '如何负责任地使用公共邮政编码数据。',
'它们不应被索引。服务器会将私有路径标记为 noindex并且站点地图仅列出公共页面。',
'How to use public postcode data responsibly.': '如何负责任地使用公共邮数据。',
'What data powers the public comparisons.': '哪些数据支持公众比较。',
'Explore public postcode-search workflows.': '探索公共邮政编码搜索工作流程。',
'Explore public postcode-search workflows.': '探索公共邮搜索工作流程。',
},
},
@ -616,8 +617,11 @@ const zh: Translations = {
clearAll: '全部清除',
clearAllTitle: '清除所有筛选条件?',
clearAllSavePrompt: '是否要在清除前保存当前的筛选条件?',
clearAllUpdatePrompt: '在清除前使用当前筛选条件更新 <strong>{{name}}</strong>',
saveAndClear: '保存并清除',
updateAndClear: '更新并清除',
clearWithoutSaving: '不保存直接清除',
clearWithoutUpdating: '不更新直接清除',
filtersOut: '筛除 {{value}}',
schoolType: '学校类型',
schoolRating: '学校评级',
@ -762,10 +766,12 @@ const zh: Translations = {
showAllStatsHint: '筛选前这里有 {{count}} 处房产。切换到全部房产即可查看该区域。',
showAllStatsFallback: '切换到全部房产即可在不应用当前筛选条件的情况下查看该区域。',
showAllStats: '显示全部房产',
closestBlockingFilters: '最接近的排除此区域的筛选条件',
closestBlockingFilters: '纳入该区域所需的最小调整',
lowerMinTo: '将最小值降至 {{value}}',
raiseMaxTo: '将最大值提高至 {{value}}',
allowCategory: '允许 {{value}}',
missingFilterValue: '此筛选条件没有值;请移除它或允许缺失值',
noFilterDataShort: '无数据',
travelTo: '前往 {{destination}} 的出行',
viewProperties: '查看 {{count}} 处房产',
viewPropertiesShort: '查看房产',
@ -831,16 +837,17 @@ const zh: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroEyebrow: '先找准该看哪里',
heroTitle1: '别再搜索',
heroTitle2: '不合适的地方',
heroTitle3: '在房源缩小您的选择之前。',
heroSubtitle: '找到预算、通勤和日常生活都匹配的邮编。',
heroDescription: 'Perfect Postcode 会先筛选每个邮编,让您只追踪真正合适地点的看房机会。',
exploreTheMap: '告诉我该看哪里',
seeTheDifference: '观看演示',
productDemoLabel: '了解如何先找准该看哪里',
playProductDemo: '播放“该看哪里”演示',
heroEyebrow: '适合正在问“我到底该看哪里?”的买家',
heroTitle1: '找到真正',
heroTitle2: '适合您生活的邮编',
heroTitle3: '不只局限于您已经知道的区域。',
heroSubtitle: '从伦敦街区到通勤城镇和英格兰各地城市,可研究的地方太多,无法一个个筛查。',
heroDescription:
'设定预算、通勤、学校、安全、噪音、宽带和生活方式需求。Perfect Postcode 会扫描英格兰的邮编,显示真正匹配的地方,包括您从未想过要在房源网站上搜索的区域。',
exploreTheMap: '找到匹配的邮编',
seeTheDifference: '查看使用方式',
productDemoLabel: 'Perfect Postcode 产品演示',
playProductDemo: '播放 Perfect Postcode 产品演示',
scrollToProductDemo: '滚动到产品演示',
showcaseHeader: '工作原理',
showcaseContext: 'Perfect Postcode 的工作流程',
@ -848,40 +855,42 @@ const zh: Translations = {
showcaseFeatureNoiseShort: '噪声',
showcaseFeatureSchoolsShort: '学校',
showcaseFeatureTravelShort: '出行',
showcaseGoodPrimariesNearby: '附近 {{count}}+ 所良好或优秀小学',
showcaseWithinRail: '距车站 {{count}} 分钟内',
showcaseMatchingHomesLabel: '匹配邮编',
showcaseMatchingHomes: '{{value}} 个匹配邮编',
showcaseGoodPrimariesNearby: '附近 {{count}}+ 所良好小学',
showcaseWithinRail: '{{count}} 分钟内到达铁路',
showcaseMatchingHomesLabel: '匹配房源',
showcaseMatchingHomes: '{{value}} 个匹配房源',
showcaseMedianPrice: '{{value}} 中位数',
showcaseJourneyRoutes: '出行路线',
showcaseNearby: '附近 {{value}} 个',
showcasePoliticalVoteShare: '政党得票份额',
showcaseLotsMore: '更多社区数据',
showcaseLotsMore: '……以及更多',
showcaseMinutes: '{{count}} 分钟',
showcaseSendShortlist: '发送候选名单',
showcaseDownloadXlsx: '下载 .xlsx',
showcaseTopThree: '前 3 名',
showcaseScoutBullet1: '订阅房源提醒前,先核查街道。',
showcaseScoutBullet1: '在房源搜索缩小选择之前,先实地走走街道。',
showcaseScoutBullet2: '从真实门牌测试通勤,而不是只看行政区名称。',
showcaseScoutBullet3: '带着已有证据比较看房结果。',
showcaseStep1Tab: '筛选',
showcaseStep1Title: '设定必须满足的条件',
showcaseStep1Body: '加入预算、通勤、学校、安全、噪音和本地细节,看不合适的邮编逐个被筛掉。',
showcaseStep1Title: '把模糊需求变成精准搜索',
showcaseStep1Body: '设置真正重要的条件,并清楚看到每项要求为您排除了多少不合适的邮编。',
showcaseStep1Chip1: '安静街道',
showcaseStep1Chip2: '附近优质小学',
showcaseStep1Chip2: '顶级小学',
showcaseStep1Chip3: '£500,000 以内',
showcaseStep1VennCenter: '同时满足三项条件的邮编',
showcaseStep2Tab: '匹配',
showcaseStep2Title: '查看剩下的可选地点',
showcaseStep2Body: '按实际条件搜索,而不是按熟悉地名搜索。地图会显示值得优先核查的邮编集群。',
showcaseStep2Title: '让地图浮现您原本不会输入的地方',
showcaseStep2Body:
'按匹配度扫描英格兰,而不是从熟悉的地名开始。房源门户缩小您的想象之前,隐藏的好区域会先显现出来。',
showcaseStep2Region: '大伦敦',
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseStep2ClustersLabel: '匹配集群',
showcaseStep3Tab: '检查',
showcaseStep3Title: '核查依据',
showcaseStep3Body: '打开一个邮编,在看房前查看价格、通勤、学校、犯罪率、宽带和取舍。',
showcaseStep3HeaderArea: '候选邮编',
showcaseStep3HeaderFit: '匹配点',
showcaseStep3Title: '查看某个邮编为什么入选',
showcaseStep3Body:
'打开任何匹配区域,在一个面板中查看价格、安全、学校、宽带和取舍,再决定是否花一个周末去实地看。',
showcaseStep3HeaderArea: '您的理想邮编',
showcaseStep3HeaderFit: '社区证据',
showcaseStep3Stat1Label: '成交价走势',
showcaseStep3Stat2Label: '犯罪率',
showcaseStep3Stat2Value: '低于本区平均水平',
@ -891,31 +900,34 @@ const zh: Translations = {
showcaseStep3Stat5Label: '小学',
showcaseStep3Stat5Value: '1英里内3所「优秀」',
showcaseStep4Tab: '踏勘',
showcaseStep4Title: '把候选名单带到实地',
showcaseStep4Body: '导出值得核查的邮编,测试通勤,走走街道,并用已保存的背景信息比较看房结果。',
showcaseStep4Title: '亲自去看一看',
showcaseStep4Body:
'带着三个有数据支撑的起点走进现实。实地走街、测试通勤,并带着背景信息比较看房结果。',
showcaseStep4FileName: 'areas-to-scout.xlsx',
showcaseStep4ExportLabel: '导出到 Excel',
showcaseStep4ColPostcode: '邮编',
showcaseStep4ColScore: '匹配',
showcaseStep4ColCommute: '通勤',
showcaseStep4ColPrice: '成交中位价',
showcaseStep4Conclusion: '导出候选名单,开始核查街道。',
statProperties: 'HM Land Registry 成交记录',
statFilters: '种缩小地图范围的方法',
statEvery: '每个',
statPostcodeInEngland: '英格兰活跃邮编',
ourPhilosophy: '别再从已经熟悉的城镇开始。',
showcaseStep4Conclusion: '您可以从这里开始。',
statProperties: '历史成交记录',
statFilters: '可组合筛选条件',
statEvery: '覆盖',
statPostcodeInEngland: '英格兰每个邮编',
ourPhilosophy: '先明确重要条件,再找到合适的邮编',
philosophyP1:
'大多数搜索先从地名开始,然后希望合适的房源会出现。这跳过了更难的问题:哪些地方真的值得搜索?',
'大多数房产网站先问您想住哪里。在伦敦这个问题尤其困难,但英格兰各地都有同样的问题:买家通常只能从几个熟悉的地方开始,然后分别查询通勤、学校、犯罪率、街景、宽带和成交价。',
philosophyP2:
'Perfect Postcode 从房源网站之前开始。设定一个地方必须支持的生活条件,然后先查看最值得关注的邮编。',
'Perfect Postcode 反过来做搜索。告诉地图什么重要,它会显示符合条件的邮编,并解释为什么值得查看。先看数据,再去现场感受。',
streetTitle: '每条街都可能不同',
streetIntro:
'车站的哪一侧、嘈杂道路或一个学校学区,都可能改变搜索结果。区域名称会抹平这些差异。',
streetCard1Title: '避开熟悉地名的陷阱',
streetCard1Body: '在已列入清单的地方之外,找到邮编级别的匹配项。',
streetCard2Title: '出发前先看清取舍',
streetCard2Body: '预约看房前,先核查价格、通勤、噪音、学校、安全、宽带和附近配套。',
'大的区域名称会掩盖关键细节:车站哪一侧、道路噪音、学校组合、真实通勤时间,以及类似房产的实际成交价。',
streetCard1Title: '发现您可能错过的区域',
streetCard1Body:
'根据您的条件找出匹配的邮编,而不是只依赖熟悉的地名、朋友推荐或“潜力区域”的宣传。',
streetCard2Title: '看房前先看清取舍',
streetCard2Body:
'在把周末花在看房之前,先比较价格、空间、通勤、安全、学校、宽带、噪音和能源评级。',
othersVs: '与其他平台对比',
checkMyPostcode: '房源门户',
areaGuides: '邮编报告',
@ -925,10 +937,10 @@ const zh: Translations = {
compAreaDataSub: '(犯罪率、学校、噪音、宽带、设施)',
compPropertyData: '房产级历史记录',
compPropertyDataSub: '成交价、EPC、面积、估值',
compFilters: '预算、通勤、学校、安全和本地数据一起筛选',
compFiltersSub: '预算 + 通勤 + 学校 + 安全 + 本地背景',
ctaTitle: '预约看房前,先找到该看哪里。',
ctaDescription: '根据真正重要的条件建立邮编候选名单,再亲自核查街道。',
compFilters: '56 项联动筛选',
compFiltersSub: '不是一次查一个邮编或一个房源',
ctaTitle: '别再猜哪里值得买。',
ctaDescription: '先建立符合真实生活需求的邮编候选名单,再去实地感受。',
},
// ── Pricing Page ───────────────────────────────────
@ -1187,6 +1199,8 @@ const zh: Translations = {
notesPlaceholder: '记下您的想法...',
deleteSearch: '删除搜索',
deleteSearchConfirm: '确定要删除这个保存的搜索吗?此操作无法撤销。',
isBeingUpdated: '正在更新 <strong>{{name}}</strong>',
updating: '更新中...',
},
// ── Invites Page ───────────────────────────────────
@ -1282,6 +1296,7 @@ const zh: Translations = {
'Property prices': '房价',
Transport: '交通',
Education: '教育',
'Defining characteristics': '主要特征',
'Area development': '区域发展',
Crime: '犯罪',
Neighbours: '邻居',

View file

@ -6,9 +6,10 @@
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
<meta name="referrer" content="no-referrer" />
<title>Stop searching the wrong places | Perfect Postcode</title>
<title>Perfect Postcode - Find where to buy before browsing listings</title>
<meta name="description" content="Filter every postcode in England by budget, commute, schools, crime, noise, broadband, property prices and amenities before you start chasing viewings." />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
<script id="perfect-postcode-bugsink-config" type="application/json">__PERFECT_POSTCODE_BUGSINK_CONFIG__</script>
<script>
(function() {
var theme = localStorage.getItem('theme');

View file

@ -1,15 +1,29 @@
import { createRoot } from 'react-dom/client';
import App from './App';
import { i18nReady } from './i18n';
import { BugsinkErrorBoundary, initBugsink } from './lib/bugsink';
import './index.css';
import './hooks/usePlausible';
initBugsink();
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
const root = container;
function AppErrorFallback() {
return (
<div className="flex min-h-screen items-center justify-center bg-warm-50 px-6 text-center text-warm-900 dark:bg-navy-950 dark:text-warm-100">
<div>
<h1 className="text-xl font-semibold">Something went wrong</h1>
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">Refresh the page to try again.</p>
</div>
</div>
);
}
function renderApp() {
const hasPrerenderedMarkup = root.children.length > 0;
@ -18,7 +32,11 @@ function renderApp() {
}
root.removeAttribute('data-prerender-path');
createRoot(root).render(<App />);
createRoot(root).render(
<BugsinkErrorBoundary fallback={<AppErrorFallback />}>
<App />
</BugsinkErrorBoundary>
);
}
void i18nReady.then(renderApp);

View file

@ -0,0 +1,100 @@
import * as Sentry from '@sentry/react';
import type { ReactElement, ReactNode } from 'react';
declare const __BUGSINK_DSN__: string | undefined;
declare const __BUGSINK_ENVIRONMENT__: string | undefined;
declare const __BUGSINK_RELEASE__: string | undefined;
declare const __BUGSINK_SEND_DEFAULT_PII__: boolean | undefined;
interface BugsinkConfig {
dsn?: string;
environment?: string;
release?: string;
sendDefaultPii?: boolean;
}
declare global {
interface Window {
__PERFECT_POSTCODE_BUGSINK__?: BugsinkConfig;
}
}
function nonempty(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function readBuildTimeString(value: unknown): string | undefined {
return nonempty(value);
}
function readBuildTimeBoolean(value: unknown): boolean {
return typeof value === 'boolean' ? value : false;
}
function readRuntimeConfig(): BugsinkConfig {
if (typeof document === 'undefined') {
return {};
}
const element = document.getElementById('perfect-postcode-bugsink-config');
const json = element?.textContent?.trim();
if (!json || json === '__PERFECT_POSTCODE_BUGSINK_CONFIG__') {
return window.__PERFECT_POSTCODE_BUGSINK__ ?? {};
}
try {
const config = JSON.parse(json) as BugsinkConfig;
window.__PERFECT_POSTCODE_BUGSINK__ = config;
return config;
} catch {
return window.__PERFECT_POSTCODE_BUGSINK__ ?? {};
}
}
export function initBugsink(): boolean {
const runtimeConfig = readRuntimeConfig();
const dsn =
nonempty(runtimeConfig.dsn) ??
readBuildTimeString(typeof __BUGSINK_DSN__ === 'string' ? __BUGSINK_DSN__ : undefined);
if (!dsn) {
return false;
}
Sentry.init({
dsn,
environment:
nonempty(runtimeConfig.environment) ??
readBuildTimeString(
typeof __BUGSINK_ENVIRONMENT__ === 'string' ? __BUGSINK_ENVIRONMENT__ : undefined
),
release:
nonempty(runtimeConfig.release) ??
readBuildTimeString(typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined),
sendDefaultPii:
runtimeConfig.sendDefaultPii ??
readBuildTimeBoolean(
typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean'
? __BUGSINK_SEND_DEFAULT_PII__
: undefined
),
tracesSampleRate: 0,
});
Sentry.setTag('app', 'frontend');
return true;
}
export function BugsinkErrorBoundary({
children,
fallback,
}: {
children: ReactNode;
fallback: ReactElement;
}) {
return <Sentry.ErrorBoundary fallback={fallback}>{children}</Sentry.ErrorBoundary>;
}

View file

@ -16,6 +16,8 @@ const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
'Property prices': TagIcon,
Transport: RouteIcon,
Education: GraduationCapIcon,
Schools: GraduationCapIcon,
'Defining characteristics': TreeIcon,
'Area development': ChartBarIcon,
Crime: ShieldIcon,
Neighbours: UsersIcon,

View file

@ -197,7 +197,7 @@ export function getSchoolFilterMeta(features: FeatureMeta[]): FeatureMeta {
return {
name: SCHOOL_FILTER_NAME,
type: 'numeric',
group: 'Education',
group: 'Schools',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 10,
step: 1,

View file

@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import type { TravelTimeEntry } from '../hooks/useTravelTime';
import { buildTravelParam, dedupeTravelTimeEntries } from './travel-params';
const bankMedian: TravelTimeEntry = {
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [0, 60],
useBest: false,
};
describe('travel-params', () => {
it('deduplicates travel entries by backend key and keeps the tightest range', () => {
const entries = dedupeTravelTimeEntries([
bankMedian,
{
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank tube station',
timeRange: [15, 45],
useBest: false,
},
{
mode: 'walking',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [0, 20],
useBest: false,
},
]);
expect(entries).toEqual([
{
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [15, 45],
useBest: false,
},
{
mode: 'walking',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [0, 20],
useBest: false,
},
]);
});
it('serializes deduplicated entries before backend requests', () => {
expect(
buildTravelParam([
bankMedian,
{
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [10, 50],
useBest: false,
},
])
).toBe('transit:bank-tube-station:10:50');
});
it('keeps duplicate blank entries because they are editable placeholders', () => {
const blank: TravelTimeEntry = {
mode: 'transit',
slug: '',
label: '',
timeRange: null,
useBest: false,
};
expect(dedupeTravelTimeEntries([blank, blank])).toHaveLength(2);
});
it('uses an unbounded range when excluding a deduplicated travel filter', () => {
expect(
buildTravelParam(
[
bankMedian,
{
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [10, 50],
useBest: false,
},
],
'tt_transit_bank-tube-station',
true
)
).toBe('transit:bank-tube-station:0:1440');
});
});

View file

@ -1,5 +1,44 @@
import type { TravelTimeEntry } from '../hooks/useTravelTime';
function mergeTimeRanges(
current: [number, number] | null,
next: [number, number] | null
): [number, number] | null {
if (!current) return next;
if (!next) return current;
return [Math.max(current[0], next[0]), Math.min(current[1], next[1])];
}
export function dedupeTravelTimeEntries(entries: TravelTimeEntry[]): TravelTimeEntry[] {
const result: TravelTimeEntry[] = [];
const indexByKey = new Map<string, number>();
for (const entry of entries) {
if (!entry.slug) {
result.push(entry);
continue;
}
const key = `${entry.mode}:${entry.slug}`;
const existingIndex = indexByKey.get(key);
if (existingIndex == null) {
indexByKey.set(key, result.length);
result.push({ ...entry });
continue;
}
const existing = result[existingIndex];
result[existingIndex] = {
...existing,
label: existing.label || entry.label,
timeRange: mergeTimeRanges(existing.timeRange, entry.timeRange),
useBest: existing.useBest || entry.useBest,
};
}
return result;
}
export function buildTravelParam(
entries: TravelTimeEntry[],
excludeFieldKey?: string,
@ -7,7 +46,7 @@ export function buildTravelParam(
): string {
const segments: string[] = [];
for (const entry of entries) {
for (const entry of dedupeTravelTimeEntries(entries)) {
if (!entry.slug) continue;
let segment = `${entry.mode}:${entry.slug}`;

View file

@ -99,6 +99,45 @@ describe('url-state', () => {
expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
});
it('deduplicates travel-time URL params with the tightest range', () => {
window.history.replaceState(
{},
'',
'/?tt=transit:bank-tube-station:Bank:0:60&tt=transit:bank-tube-station:Bank:10:45'
);
const state = parseUrlState();
expect(state.travelTime?.entries).toEqual([
{
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [10, 45],
useBest: false,
},
]);
const params = stateToParams(null, {}, [], new Set(), 'area', [
{
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
useBest: false,
timeRange: [0, 60],
},
{
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
useBest: false,
timeRange: [10, 45],
},
]);
expect(params.getAll('tt')).toEqual(['transit:bank-tube-station:Bank:10:45']);
});
it('round-trips an explicitly empty POI selection', () => {
const params = stateToParams(null, {}, [], new Set(), 'area');

View file

@ -111,6 +111,33 @@ export interface POIResponse {
pois: POI[];
}
export interface ActualListing {
lat: number;
lon: number;
postcode: string;
address?: string;
property_type?: string;
property_sub_type?: string;
leasehold_freehold?: string;
price_qualifier?: string;
bedrooms?: number;
bathrooms?: number;
rooms_total?: number;
floor_area_sqm?: number;
asking_price?: number;
asking_price_per_sqm?: number;
listing_url: string;
listing_status?: string;
listing_date_iso?: string;
features: string[];
}
export interface ActualListingsResponse {
listings: ActualListing[];
total: number;
truncated: boolean;
}
export interface POICategoryGroup {
name: string;
categories: string[];

View file

@ -55,9 +55,14 @@ module.exports = {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'indeterminate-progress': {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(400%)' },
},
},
animation: {
'fade-in': 'fade-in 0.2s ease-out forwards',
'indeterminate-progress': 'indeterminate-progress 1.1s ease-in-out infinite',
},
},
},

View file

@ -6,11 +6,36 @@ const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
const sharp = require('sharp');
const webpack = require('webpack');
const packageJson = require('./package.json');
const HOUSE_IMAGE_WIDTH = 260;
function envString(...names) {
for (const name of names) {
const value = process.env[name];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
}
return undefined;
}
function envBoolean(name, fallback = false) {
const value = process.env[name];
if (typeof value !== 'string' || value.trim().length === 0) {
return fallback;
}
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}
module.exports = (env, argv) => {
const isProduction = argv.mode === 'production';
const bugsinkEnvironment =
envString('FRONTEND_BUGSINK_ENVIRONMENT', 'BUGSINK_ENVIRONMENT', 'SENTRY_ENVIRONMENT') ||
(isProduction ? 'production' : 'development');
const bugsinkRelease =
envString('FRONTEND_BUGSINK_RELEASE', 'BUGSINK_RELEASE', 'SENTRY_RELEASE') ||
`${packageJson.name}@${packageJson.version}`;
return {
entry: './src/index.tsx',
@ -22,6 +47,7 @@ module.exports = (env, argv) => {
publicPath: '/',
},
devtool: isProduction ? 'hidden-source-map' : 'eval-cheap-module-source-map',
resolve: {
extensions: ['.ts', '.tsx', '.js'],
},
@ -62,6 +88,14 @@ module.exports = (env, argv) => {
plugins: [
new webpack.DefinePlugin({
__DEV__: JSON.stringify(!isProduction),
__BUGSINK_DSN__: JSON.stringify(
envString('FRONTEND_BUGSINK_DSN', 'PUBLIC_BUGSINK_DSN', 'BUGSINK_DSN') || ''
),
__BUGSINK_ENVIRONMENT__: JSON.stringify(bugsinkEnvironment),
__BUGSINK_RELEASE__: JSON.stringify(bugsinkRelease),
__BUGSINK_SEND_DEFAULT_PII__: JSON.stringify(
envBoolean('BUGSINK_SEND_DEFAULT_PII', false)
),
}),
new HtmlWebpackPlugin({
template: './src/index.html',

View file

@ -1,7 +1,9 @@
"""Download police.uk crime archive ZIPs.
The archive page lists rolling monthly snapshots. Newer snapshots overlap older
ones, so extraction keeps files already written by newer archives.
ones. By default this downloader selects non-overlapping snapshots, extracts
only street-crime CSVs, and removes each ZIP after extraction so disk use is
bounded by extracted street CSVs plus one temporary archive.
"""
from __future__ import annotations
@ -33,6 +35,38 @@ ARCHIVE_LINK_RE = re.compile(
)
VALID_MD5_RE = re.compile(r"^[0-9a-fA-F]{32}$")
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
STREET_CRIME_CSV_RE = re.compile(r"^\d{4}-\d{2}-.+-street\.csv$")
CONTAINED_RANGE_RE = re.compile(
r"Contains data from (?P<start_month>[A-Za-z]+) (?P<start_year>\d{4}) "
r"to (?P<end_month>[A-Za-z]+) (?P<end_year>\d{4})",
re.IGNORECASE,
)
MONTH_NAMES = {
"jan": 1,
"january": 1,
"feb": 2,
"february": 2,
"mar": 3,
"march": 3,
"apr": 4,
"april": 4,
"may": 5,
"jun": 6,
"june": 6,
"jul": 7,
"july": 7,
"aug": 8,
"august": 8,
"sep": 9,
"sept": 9,
"september": 9,
"oct": 10,
"october": 10,
"nov": 11,
"november": 11,
"dec": 12,
"december": 12,
}
@dataclass(frozen=True)
@ -110,6 +144,74 @@ def filter_archives(
return filtered
def _month_to_index(month: str) -> int:
year, month_num = (int(part) for part in month.split("-"))
return year * 12 + month_num
def _format_month(year: int, month_name: str) -> str | None:
month_num = MONTH_NAMES.get(month_name.strip().lower())
if month_num is None:
return None
return f"{year:04d}-{month_num:02d}"
def parse_contained_range(contained_range: str) -> tuple[str, str] | None:
"""Return inclusive YYYY-MM bounds from a police.uk contained-range label."""
match = CONTAINED_RANGE_RE.search(contained_range)
if match is None:
return None
start = _format_month(
int(match.group("start_year")), match.group("start_month")
)
end = _format_month(int(match.group("end_year")), match.group("end_month"))
if start is None or end is None:
return None
return start, end
def select_coverage_archives(archives: list[CrimeArchive]) -> list[CrimeArchive]:
"""Select non-overlapping snapshots that still cover the available history.
The source publishes rolling multi-year snapshots. Downloading every monthly
snapshot mostly fetches duplicate data; for our aggregate LSOA counts we only
need continuous month coverage.
"""
selected: list[CrimeArchive] = []
earliest_covered_start: int | None = None
def sort_key(archive: CrimeArchive) -> int:
parsed_range = parse_contained_range(archive.contained_range)
if parsed_range is not None:
return _month_to_index(parsed_range[1])
return _month_to_index(archive.month)
for archive in sorted(archives, key=sort_key, reverse=True):
parsed_range = parse_contained_range(archive.contained_range)
if parsed_range is None:
selected.append(archive)
continue
start, end = parsed_range
start_index = _month_to_index(start)
end_index = _month_to_index(end)
if earliest_covered_start is None or end_index < earliest_covered_start:
if (
earliest_covered_start is not None
and end_index < earliest_covered_start - 1
):
print(
"Warning: archive ranges are not adjacent; "
f"coverage gap before {archive.filename}",
file=sys.stderr,
)
selected.append(archive)
earliest_covered_start = start_index
return selected
def file_md5(path: Path) -> str:
digest = hashlib.md5()
with path.open("rb") as file:
@ -198,8 +300,14 @@ def download_archive(
return dest
def _is_street_crime_csv(path: PurePosixPath | Path) -> bool:
return STREET_CRIME_CSV_RE.fullmatch(path.name) is not None
def _safe_csv_members(
archive: zipfile.ZipFile,
*,
street_only: bool,
) -> list[tuple[zipfile.ZipInfo, PurePosixPath]]:
members: list[tuple[zipfile.ZipInfo, PurePosixPath]] = []
for info in archive.infolist():
@ -211,6 +319,8 @@ def _safe_csv_members(
or rel_path.suffix.lower() != ".csv"
):
continue
if street_only and not _is_street_crime_csv(rel_path):
continue
members.append((info, rel_path))
return members
@ -220,13 +330,14 @@ def extract_csvs(
output_dir: Path,
*,
overwrite: bool = False,
street_only: bool = True,
) -> tuple[int, int]:
"""Extract CSVs from one ZIP. Returns (extracted, skipped)."""
extracted = 0
skipped = 0
with zipfile.ZipFile(zip_path) as archive:
for info, rel_path in _safe_csv_members(archive):
for info, rel_path in _safe_csv_members(archive, street_only=street_only):
dest = output_dir.joinpath(*rel_path.parts)
if dest.exists() and not overwrite:
skipped += 1
@ -240,12 +351,65 @@ def extract_csvs(
return extracted, skipped
def _remove_path(path: Path) -> None:
if not path.exists() and not path.is_symlink():
return
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
def prepare_archive_dir(output_dir: Path, *, keep_archives: bool) -> Path:
"""Return the archive work dir, pruning retained ZIP caches by default."""
retained_archive_dir = output_dir / "_archives"
temp_archive_dir = output_dir / "_download_tmp"
if keep_archives:
retained_archive_dir.mkdir(parents=True, exist_ok=True)
return retained_archive_dir
if retained_archive_dir.exists() or retained_archive_dir.is_symlink():
print(f"Removing retained ZIP cache: {retained_archive_dir}")
_remove_path(retained_archive_dir)
_remove_path(temp_archive_dir)
temp_archive_dir.mkdir(parents=True, exist_ok=True)
return temp_archive_dir
def prune_unused_csvs(output_dir: Path) -> tuple[int, int]:
"""Remove extracted non-street CSVs left by older downloader versions."""
removed = 0
bytes_removed = 0
for path in output_dir.rglob("*.csv"):
if _is_street_crime_csv(path):
continue
try:
bytes_removed += path.stat().st_size
except OSError:
pass
path.unlink()
removed += 1
return removed, bytes_removed
def write_manifest(
output_dir: Path, archive_url: str, archives: list[CrimeArchive]
output_dir: Path,
archive_url: str,
archives: list[CrimeArchive],
*,
available_archive_count: int,
archive_strategy: str,
keep_archives: bool,
street_only: bool,
) -> None:
manifest = {
"source": archive_url,
"fetched_at": datetime.now(UTC).isoformat(),
"available_archive_count": available_archive_count,
"archive_strategy": archive_strategy,
"keep_archives": keep_archives,
"street_only": street_only,
"archives": [asdict(archive) for archive in archives],
}
path = output_dir / "archive_manifest.json"
@ -260,13 +424,13 @@ def _month_arg(value: str) -> str:
def main() -> None:
parser = argparse.ArgumentParser(
description="Download all monthly police.uk crime archive ZIPs"
description="Download police.uk crime archives needed for street-crime aggregates"
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Directory for extracted CSVs; ZIPs are kept under _archives/",
help="Directory for extracted street-crime CSVs; ZIPs are temporary unless --keep-archives is set",
)
parser.add_argument(
"--archive-url",
@ -288,6 +452,15 @@ def main() -> None:
type=int,
help="Download at most this many archives after filtering",
)
parser.add_argument(
"--archive-strategy",
choices=("coverage", "all"),
default="coverage",
help=(
"coverage selects non-overlapping snapshots for continuous month "
"coverage; all downloads every matching monthly snapshot"
),
)
parser.add_argument(
"--list",
action="store_true",
@ -299,6 +472,21 @@ def main() -> None:
action="store_false",
help="Download ZIPs only; do not extract CSVs",
)
parser.add_argument(
"--extract-all-csvs",
action="store_true",
help="Extract outcomes and stop/search CSVs as well as street-crime CSVs",
)
parser.add_argument(
"--keep-archives",
action="store_true",
help="Retain downloaded ZIPs under _archives/ instead of deleting them after extraction",
)
parser.add_argument(
"--keep-unused-csvs",
action="store_true",
help="Do not prune non-street CSVs left by older downloader runs",
)
parser.add_argument(
"--overwrite-extracted",
action="store_true",
@ -322,14 +510,21 @@ def main() -> None:
help="Per-read timeout in seconds for large ZIP downloads",
)
args = parser.parse_args()
if not args.extract and not args.keep_archives:
raise SystemExit("--no-extract requires --keep-archives")
print("Fetching police.uk archive index...")
archives = filter_archives(
available_archives = filter_archives(
fetch_archives(args.archive_url),
from_month=args.from_month,
to_month=args.to_month,
limit=args.limit,
)
archives = (
select_coverage_archives(available_archives)
if args.archive_strategy == "coverage"
else available_archives
)
if not archives:
raise SystemExit("No archives matched the requested filters")
@ -344,16 +539,34 @@ def main() -> None:
file=sys.stderr,
)
print(f"Found {len(archives)} monthly archive ZIPs")
print(
f"Selected {len(archives)} of {len(available_archives)} matching monthly "
f"archive ZIPs using {args.archive_strategy!r} strategy"
)
if args.list:
for archive in archives:
print(f"{archive.month}\t{archive.url}\t{archive.raw_md5}")
return
args.output.mkdir(parents=True, exist_ok=True)
archive_dir = args.output / "_archives"
archive_dir.mkdir(parents=True, exist_ok=True)
write_manifest(args.output, args.archive_url, archives)
archive_dir = prepare_archive_dir(args.output, keep_archives=args.keep_archives)
if not args.keep_unused_csvs:
removed, bytes_removed = prune_unused_csvs(args.output)
if removed:
print(
f"Removed {removed} unused non-street CSVs "
f"({bytes_removed / 1024 / 1024:.1f} MiB)"
)
street_only = not args.extract_all_csvs
write_manifest(
args.output,
args.archive_url,
archives,
available_archive_count=len(available_archives),
archive_strategy=args.archive_strategy,
keep_archives=args.keep_archives,
street_only=street_only,
)
total_extracted = 0
total_skipped = 0
@ -371,6 +584,7 @@ def main() -> None:
zip_path,
args.output,
overwrite=args.overwrite_extracted,
street_only=street_only,
)
total_extracted += extracted
total_skipped += skipped
@ -378,10 +592,19 @@ def main() -> None:
f"{archive.filename}: extracted {extracted} CSVs"
+ (f", skipped {skipped} existing CSVs" if skipped else "")
)
if not args.keep_archives:
zip_path.unlink(missing_ok=True)
if args.extract:
if not args.keep_archives:
_remove_path(archive_dir)
print(
f"Done. ZIPs saved in {archive_dir}; extracted {total_extracted} CSVs"
(
f"Done. ZIPs saved in {archive_dir}; "
if args.keep_archives
else "Done. ZIPs were temporary; "
)
+ f"extracted {total_extracted} CSVs"
+ (f" and skipped {total_skipped} existing CSVs" if total_skipped else "")
+ "."
)

View file

@ -10,9 +10,12 @@ import argparse
import re
from pathlib import Path
import numpy as np
import osmium
import polars as pl
from scipy.spatial import cKDTree
from shapely.geometry import Point
from pyproj import Transformer
from tqdm import tqdm
from pipeline.utils.england_geometry import (
@ -39,6 +42,12 @@ SEARCH_PLACE_TYPES = {
"island",
}
TRAVEL_DESTINATION_PLACE_TYPES = {"city"}
ENGLAND_COUNTRY_CODE = "E92000001"
LONDON_REGION_CODE = "E12000007"
LONDON_LAD_PREFIX = "E09"
LONDON_COUNTY_CODES = {"E13000001", "E13000002"}
DISPLAY_CITY_NEAREST_POSTCODE_MAX_M = 3_000
WGS84_TO_BNG = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True)
# Suffixes to strip from raw station names before appending the typed suffix.
_STATION_STRIP = (
@ -55,6 +64,7 @@ _STATION_STRIP = (
_DLR_CODE_RE = re.compile(r"ZZDL([A-Z0-9]{3})")
_POSTCODE_RE = re.compile(r"\b([A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2})\b", re.I)
_LONDON_TOKEN_RE = re.compile(r"(^|[^a-z])london([^a-z]|$)", re.I)
_NOISY_PROVIDER_SUFFIXES = (
" higher education corporation",
@ -152,8 +162,7 @@ def _find_header_row(rows: list[tuple]) -> int:
for idx, row in enumerate(rows):
keys = [_header_key(value) for value in row]
has_legal_name = any(
all(token in key for token in ("provider", "legal", "name"))
for key in keys
all(token in key for token in ("provider", "legal", "name")) for key in keys
)
has_university_title = any(
all(token in key for token in ("right", "use", "university"))
@ -235,13 +244,94 @@ def _postcode_lookup(postcodes_path: Path) -> dict[str, tuple[float, float]]:
df = pl.read_parquet(
postcodes_path,
columns=["pcds", "lat", "long", "ctry25cd", "doterm"],
).filter((pl.col("ctry25cd") == "E92000001") & pl.col("doterm").is_null())
).filter((pl.col("ctry25cd") == ENGLAND_COUNTRY_CODE) & pl.col("doterm").is_null())
return {
_normalize_postcode(postcode): (float(lat), float(lon))
for postcode, lat, lon in df.select(["pcds", "lat", "long"]).iter_rows()
}
def _display_city_from_tags(tags: dict[str, str]) -> str | None:
"""Use explicit OSM context where available, before we fall back to admin data."""
for key in (
"is_in",
"is_in:city",
"is_in:town",
"is_in:county",
"addr:city",
):
value = tags.get(key)
if value and _LONDON_TOKEN_RE.search(value):
return "London"
return None
def _is_london_admin_expr() -> pl.Expr:
return (
(pl.col("rgn25cd") == LONDON_REGION_CODE)
| pl.col("lad25cd").str.starts_with(LONDON_LAD_PREFIX).fill_null(False)
| pl.col("cty25cd").is_in(LONDON_COUNTY_CODES)
)
def _london_postcode_tree(postcodes_path: Path) -> tuple[cKDTree, np.ndarray]:
required = [
"doterm",
"ctry25cd",
"east1m",
"north1m",
"rgn25cd",
"lad25cd",
"cty25cd",
]
df = (
pl.read_parquet(postcodes_path, columns=required)
.filter(
(pl.col("ctry25cd") == ENGLAND_COUNTRY_CODE) & pl.col("doterm").is_null()
)
.filter(pl.col("east1m").is_not_null() & pl.col("north1m").is_not_null())
.with_columns(_is_london_admin_expr().alias("is_london"))
.select("east1m", "north1m", "is_london")
)
if df.is_empty():
raise ValueError(f"No active England postcodes in {postcodes_path}")
coords = np.column_stack(
[
df["east1m"].to_numpy().astype(np.float64),
df["north1m"].to_numpy().astype(np.float64),
]
)
london_flags = df["is_london"].to_numpy().astype(bool)
return cKDTree(coords), london_flags
def _assign_london_display_city(
places: list[dict],
postcodes_path: Path,
max_distance_m: float = DISPLAY_CITY_NEAREST_POSTCODE_MAX_M,
) -> int:
"""Tag places whose nearest active postcode is inside Greater London."""
if not places:
return 0
tree, london_flags = _london_postcode_tree(postcodes_path)
lons = np.array([float(place["lon"]) for place in places], dtype=np.float64)
lats = np.array([float(place["lat"]) for place in places], dtype=np.float64)
eastings, northings = WGS84_TO_BNG.transform(lons, lats)
place_coords = np.column_stack([eastings, northings])
distances, indices = tree.query(place_coords)
assigned = 0
for idx, place in enumerate(places):
if place.get("display_city") or place.get("place_type") == "city":
continue
if distances[idx] <= max_distance_m and london_flags[indices[idx]]:
place["display_city"] = "London"
assigned += 1
return assigned
def _ofs_universities(
raw: pl.DataFrame, postcode_coords: dict[str, tuple[float, float]]
) -> tuple[list[dict], int]:
@ -277,6 +367,7 @@ def _ofs_universities(
"lon": lon,
"population": 0,
"travel_destination": True,
"display_city": None,
}
)
@ -354,6 +445,7 @@ def _naptan_dlr_stations(naptan_path: Path) -> list[dict]:
"lon": station["lon_sum"] / count,
"population": 0,
"travel_destination": True,
"display_city": None,
}
)
@ -388,6 +480,7 @@ class PlaceHandler(osmium.SimpleHandler):
lon: float,
population: int,
travel_destination: bool,
display_city: str | None = None,
) -> None:
self.places.append(
{
@ -397,6 +490,7 @@ class PlaceHandler(osmium.SimpleHandler):
"lon": lon,
"population": population,
"travel_destination": travel_destination,
"display_city": display_city,
}
)
self._progress.set_postfix(places=f"{len(self.places):,}", refresh=False)
@ -414,18 +508,19 @@ class PlaceHandler(osmium.SimpleHandler):
if not self._england.contains(Point(lon, lat)):
return
name = n.tags.get("name:en", n.tags.get("name", ""))
tags = dict(n.tags)
name = tags.get("name:en", tags.get("name", ""))
if not name:
return
pop_str = n.tags.get("population", "")
pop_str = tags.get("population", "")
try:
population = int(pop_str)
except ValueError:
population = 0
# place=* nodes
place_type = n.tags.get("place")
place_type = tags.get("place")
if place_type in SEARCH_PLACE_TYPES:
self._add(
name,
@ -434,12 +529,14 @@ class PlaceHandler(osmium.SimpleHandler):
lon,
population,
travel_destination=place_type in TRAVEL_DESTINATION_PLACE_TYPES,
display_city=None
if place_type == "city"
else _display_city_from_tags(tags),
)
return
# Railway stations (tube, national rail, DLR, overground, Elizabeth line)
if n.tags.get("railway") == "station":
tags = dict(n.tags)
if tags.get("railway") == "station":
if _is_tram_station(tags):
return
display_name = _station_display_name(name, tags)
@ -450,6 +547,7 @@ class PlaceHandler(osmium.SimpleHandler):
lon,
population,
travel_destination=True,
display_city=_display_city_from_tags(tags),
)
return
@ -479,7 +577,10 @@ def main() -> None:
parser.add_argument(
"--postcodes",
type=Path,
help="Postcode parquet used to geocode OfS university contact postcodes",
help=(
"Postcode parquet used to geocode OfS university contact postcodes "
"and assign Greater London display labels"
),
)
args = parser.parse_args()
@ -507,14 +608,18 @@ def main() -> None:
added, skipped = _append_ofs_universities(
handler.places, args.university_register, args.postcodes
)
print(
f"Added {added:,} university travel destinations from the OfS register"
)
print(f"Added {added:,} university travel destinations from the OfS register")
if skipped:
print(f"Skipped {skipped:,} OfS university rows without usable coordinates")
if handler.places:
if args.postcodes:
assigned = _assign_london_display_city(handler.places, args.postcodes)
print(f"Assigned London display labels to {assigned:,} places")
for place in handler.places:
place.setdefault("display_city", None)
df = pl.DataFrame(handler.places)
df = df.with_columns(pl.col("display_city").cast(pl.Utf8))
args.output.parent.mkdir(parents=True, exist_ok=True)
df.write_parquet(args.output)
print(f"Saved to {args.output}")

View file

@ -1,6 +1,13 @@
from zipfile import ZipFile
from pipeline.download.crime import extract_csvs, parse_archives
from pipeline.download.crime import (
CrimeArchive,
extract_csvs,
prepare_archive_dir,
prune_unused_csvs,
select_coverage_archives,
parse_archives,
)
def test_parse_archives_reads_monthly_zip_links_only():
@ -48,6 +55,8 @@ def test_extract_csvs_preserves_existing_newer_files(tmp_path):
with ZipFile(zip_path, "w") as archive:
archive.writestr("2023-01/2023-01-city-street.csv", "older\n")
archive.writestr("2022-12/2022-12-city-street.csv", "old\n")
archive.writestr("2022-12/2022-12-city-outcomes.csv", "unused\n")
archive.writestr("2022-12/2022-12-city-stop-and-search.csv", "unused\n")
archive.writestr("../escape.csv", "bad\n")
archive.writestr("notes.txt", "ignored\n")
@ -57,4 +66,68 @@ def test_extract_csvs_preserves_existing_newer_files(tmp_path):
assert skipped == 1
assert existing.read_text() == "newer\n"
assert (output / "2022-12" / "2022-12-city-street.csv").read_text() == "old\n"
assert not (output / "2022-12" / "2022-12-city-outcomes.csv").exists()
assert not (output / "2022-12" / "2022-12-city-stop-and-search.csv").exists()
assert not (tmp_path / "escape.csv").exists()
def _archive(month: str, contained_range: str) -> CrimeArchive:
return CrimeArchive(
month=month,
label=month,
url=f"https://data.police.uk/data/archive/{month}.zip",
filename=f"{month}.zip",
size="1.0 GB",
contained_range=contained_range,
md5=None,
raw_md5="",
)
def test_select_coverage_archives_skips_overlapping_snapshots():
archives = [
_archive("2026-03", "Contains data from Apr 2023 to Mar 2026"),
_archive("2026-02", "Contains data from Mar 2023 to Feb 2026"),
_archive("2023-04", "Contains data from May 2020 to Apr 2023"),
_archive("2023-03", "Contains data from Apr 2020 to Mar 2023"),
]
selected = select_coverage_archives(archives)
assert [archive.month for archive in selected] == ["2026-03", "2023-03"]
def test_prepare_archive_dir_removes_retained_zip_cache_by_default(tmp_path):
output = tmp_path / "crime"
retained = output / "_archives"
temp = output / "_download_tmp"
retained.mkdir(parents=True)
temp.mkdir()
(retained / "old.zip").write_text("zip\n")
(temp / "old.zip.part").write_text("part\n")
archive_dir = prepare_archive_dir(output, keep_archives=False)
assert archive_dir == temp
assert archive_dir.exists()
assert list(archive_dir.iterdir()) == []
assert not retained.exists()
def test_prune_unused_csvs_removes_non_street_csvs(tmp_path):
output = tmp_path / "crime"
month_dir = output / "2024-01"
month_dir.mkdir(parents=True)
street = month_dir / "2024-01-city-street.csv"
outcomes = month_dir / "2024-01-city-outcomes.csv"
stop_search = month_dir / "2024-01-city-stop-and-search.csv"
street.write_text("street\n")
outcomes.write_text("outcomes\n")
stop_search.write_text("stop\n")
removed, _ = prune_unused_csvs(output)
assert removed == 2
assert street.exists()
assert not outcomes.exists()
assert not stop_search.exists()

View file

@ -1,6 +1,9 @@
import polars as pl
from pyproj import Transformer
from pipeline.download.places import (
_assign_london_display_city,
_display_city_from_tags,
_is_dlr_station,
_is_tram_station,
_naptan_dlr_stations,
@ -9,6 +12,22 @@ from pipeline.download.places import (
_station_display_name,
)
WGS84_TO_BNG = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True)
def _postcode_row(postcode: str, lat: float, lon: float, *, london: bool) -> dict:
easting, northing = WGS84_TO_BNG.transform(lon, lat)
return {
"pcds": postcode,
"doterm": None,
"ctry25cd": "E92000001",
"east1m": int(round(easting)),
"north1m": int(round(northing)),
"rgn25cd": "E12000007" if london else "E12000008",
"lad25cd": "E09000008" if london else "E07000208",
"cty25cd": "E13000002" if london else "E10000030",
}
def test_dlr_light_rail_is_not_treated_as_tram():
dlr_tags = {
@ -144,5 +163,56 @@ def test_ofs_universities_extracts_university_title_rows_with_postcode_coords():
"lon": -1.2643,
"population": 0,
"travel_destination": True,
"display_city": None,
}
]
def test_display_city_from_tags_uses_explicit_london_context():
assert _display_city_from_tags({"is_in": "Croydon, London, UK"}) == "London"
assert _display_city_from_tags({"is_in": "Croydon, Cambridgeshire, UK"}) is None
def test_assign_london_display_city_uses_nearest_active_postcode_admin(tmp_path):
postcodes = tmp_path / "postcodes.parquet"
pl.DataFrame(
[
_postcode_row("CR0 1SZ", 51.371273, -0.101793, london=True),
_postcode_row("KT19 8AG", 51.3326, -0.2678, london=False),
]
).write_parquet(postcodes)
places = [
{
"name": "Croydon",
"place_type": "town",
"lat": 51.3713049,
"lon": -0.101957,
"population": 173314,
"travel_destination": False,
"display_city": None,
},
{
"name": "East Croydon railway station",
"place_type": "station",
"lat": 51.375845,
"lon": -0.092732,
"population": 0,
"travel_destination": True,
"display_city": None,
},
{
"name": "Epsom",
"place_type": "town",
"lat": 51.3326,
"lon": -0.2678,
"population": 31489,
"travel_destination": False,
"display_city": None,
},
]
assigned = _assign_london_display_city(places, postcodes)
assert assigned == 2
assert [place["display_city"] for place in places] == ["London", "London", None]

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -41,8 +41,8 @@ dev = [
]
[tool.deptry]
# analyses/ and scripts/ use transitive deps; video/tts has its own UV project.
exclude = ["\\.venv", "analyses", "scripts", "video/tts"]
# analyses/ and scripts/ use transitive deps; finder/ and video/tts have their own UV projects.
extend_exclude = [".*/\\.venv", "analyses", "scripts", "finder", "video/tts"]
[tool.deptry.per_rule_ignores]
# pyarrow/fastexcel: runtime backends for polars parquet/Excel I/O

View file

@ -21,8 +21,8 @@ set -euo pipefail
# --demo only compute Bank + TCR, transit only (quick test)
# --- Defaults ---
THREADS=12
HEAP=48g
THREADS=6
HEAP=40g
NETWORK_DIR=property-data/r5-network
OUTPUT_BASE=property-data/travel-times
R5_DIR=r5-java
@ -139,10 +139,15 @@ if [ ! -f "$NETWORK_DIR/network.dat" ]; then
fi
# --- Step 5: Run batch ---
# Use a repo-local temp dir so DuckDB's JNI .so can be mapped executable
# (system /tmp is often mounted noexec, which breaks System.load).
TMP_DIR="$R5_DIR/tmp"
mkdir -p "$TMP_DIR"
echo ""
echo "--- Starting batch computation ---"
DATA_DIR="$NETWORK_DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \
java -Xms"$HEAP" -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
java -Xms"$HEAP" -Xmx"$HEAP" -Djava.io.tmpdir="$TMP_DIR" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
--postcodes property-data/arcgis_data.parquet \
--places property-data/places.parquet \
--output-dir "$OUTPUT_BASE" \

456
server-rs/Cargo.lock generated
View file

@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
@ -683,6 +692,21 @@ dependencies = [
"tracing",
]
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link 0.2.1",
]
[[package]]
name = "base16ct"
version = "0.1.1"
@ -772,6 +796,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "boxcar"
version = "0.2.14"
@ -1295,6 +1328,16 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2"
[[package]]
name = "debugid"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
dependencies = [
"serde",
"uuid",
]
[[package]]
name = "der"
version = "0.6.1"
@ -1338,6 +1381,16 @@ dependencies = [
"ctutils",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"objc2",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@ -1532,6 +1585,18 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "findshlibs"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64"
dependencies = [
"cc",
"lazy_static",
"libc",
"winapi",
]
[[package]]
name = "flate2"
version = "1.1.9"
@ -1773,6 +1838,12 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "glob"
version = "0.3.3"
@ -1947,6 +2018,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link 0.2.1",
]
[[package]]
name = "http"
version = "0.2.12"
@ -2675,6 +2757,18 @@ dependencies = [
"uuid",
]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "now"
version = "0.1.3"
@ -2738,6 +2832,36 @@ dependencies = [
"libm",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@ -2745,6 +2869,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
[[package]]
@ -2757,6 +2947,60 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags",
"block2",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-location",
"objc2-core-text",
"objc2-foundation",
"objc2-quartz-core",
"objc2-user-notifications",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "object"
version = "0.37.3"
@ -2831,6 +3075,22 @@ dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "os_info"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
dependencies = [
"android_system_properties",
"log",
"nix",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"serde",
"windows-sys 0.61.2",
]
[[package]]
name = "outref"
version = "0.5.2"
@ -2926,6 +3186,26 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@ -3616,6 +3896,7 @@ dependencies = [
"reqwest 0.13.3",
"rust_xlsxwriter",
"rustc-hash",
"sentry",
"serde",
"serde_json",
"sha2 0.11.0",
@ -3935,6 +4216,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2 0.4.14",
@ -4111,6 +4393,12 @@ dependencies = [
"zip",
]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc-hash"
version = "2.1.2"
@ -4346,6 +4634,130 @@ version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "sentry"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92d893ba7469d361a6958522fa440e4e2bc8bf4c5803cd1bf40b9af63f8f9a8"
dependencies = [
"cfg_aliases",
"httpdate",
"reqwest 0.12.28",
"rustls 0.23.40",
"sentry-backtrace",
"sentry-contexts",
"sentry-core",
"sentry-debug-images",
"sentry-panic",
"sentry-tower",
"sentry-tracing",
"tokio",
"ureq",
]
[[package]]
name = "sentry-backtrace"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f8784d0a27b5cd4b5f75769ffc84f0b7580e3c35e1af9cd83cb90b612d769cc"
dependencies = [
"backtrace",
"regex",
"sentry-core",
]
[[package]]
name = "sentry-contexts"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e5eb42f4cd4f9fdfec9e3b07b25a4c9769df83d218a7e846658984d5948ad3e"
dependencies = [
"hostname",
"libc",
"os_info",
"rustc_version",
"sentry-core",
"uname",
]
[[package]]
name = "sentry-core"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0b1e7ca40f965db239da279bf278d87b7407469b98835f27f0c8e59ed189b06"
dependencies = [
"rand 0.9.4",
"sentry-types",
"serde",
"serde_json",
"url",
]
[[package]]
name = "sentry-debug-images"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "002561e49ea3a9de316e2efadc40fae553921b8ff41448f02ea85fd135a778d6"
dependencies = [
"findshlibs",
"sentry-core",
]
[[package]]
name = "sentry-panic"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8906f8be87aea5ac7ef937323fb655d66607427f61007b99b7cb3504dc5a156c"
dependencies = [
"sentry-backtrace",
"sentry-core",
]
[[package]]
name = "sentry-tower"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56aebe376310840b49dad4cca55c7b32d9abdc14946cd071d4158ecb149b63a4"
dependencies = [
"axum",
"http 1.4.0",
"pin-project",
"sentry-core",
"tower-layer",
"tower-service",
"url",
]
[[package]]
name = "sentry-tracing"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b07eefe04486316c57aba08ab53dd44753c25102d1d3fe05775cc93a13262d9"
dependencies = [
"bitflags",
"sentry-backtrace",
"sentry-core",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "sentry-types"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567711f01f86a842057e1fc17779eba33a336004227e1a1e7e6cc2599e22e259"
dependencies = [
"debugid",
"hex",
"rand 0.9.4",
"serde",
"serde_json",
"thiserror",
"time",
"url",
"uuid",
]
[[package]]
name = "serde"
version = "1.0.228"
@ -5147,6 +5559,15 @@ version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "uname"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8"
dependencies = [
"libc",
]
[[package]]
name = "unicase"
version = "2.9.0"
@ -5207,6 +5628,34 @@ version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64",
"log",
"percent-encoding",
"rustls 0.23.40",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64",
"http 1.4.0",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@ -5217,6 +5666,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
@ -5225,6 +5675,12 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"

View file

@ -33,6 +33,7 @@ sha2 = "0.11"
hex = "0.4"
tower = { version = "0.5", features = ["limit"] }
libc = "0.2"
sentry = { version = "0.46.0", default-features = false, features = ["backtrace", "contexts", "debug-images", "panic", "reqwest", "rustls", "tracing", "tower-http", "tower-axum-matched-path"] }
[lints.clippy]
min_ident_chars = "warn"

80
server-rs/src/bugsink.rs Normal file
View file

@ -0,0 +1,80 @@
use std::borrow::Cow;
use serde::Serialize;
#[derive(Clone, Debug)]
pub struct BackendConfig {
pub dsn: Option<String>,
pub environment: Option<String>,
pub release: Option<String>,
pub send_default_pii: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontendConfig {
pub dsn: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release: Option<String>,
pub send_default_pii: bool,
}
pub fn env_nonempty(name: &str) -> Option<String> {
std::env::var(name).ok().and_then(nonempty)
}
pub fn nonempty(value: String) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
pub fn default_release() -> String {
format!("{}@{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
}
pub fn init_backend(config: &BackendConfig) -> Option<sentry::ClientInitGuard> {
let dsn = config.dsn.clone().and_then(nonempty)?;
let dsn = match dsn.parse::<sentry::types::Dsn>() {
Ok(dsn) => dsn,
Err(err) => {
eprintln!("Ignoring invalid BUGSINK_DSN: {err}");
return None;
}
};
Some(sentry::init(sentry::ClientOptions {
dsn: Some(dsn),
environment: config
.environment
.clone()
.and_then(nonempty)
.map(Cow::Owned),
release: Some(Cow::Owned(
config
.release
.clone()
.and_then(nonempty)
.unwrap_or_else(default_release),
)),
send_default_pii: config.send_default_pii,
traces_sample_rate: 0.0,
..Default::default()
}))
}
pub fn frontend_config(
dsn: Option<String>,
environment: Option<String>,
release: Option<String>,
send_default_pii: bool,
) -> Option<FrontendConfig> {
dsn.and_then(nonempty).map(|dsn| FrontendConfig {
dsn,
environment: environment.and_then(nonempty),
release: release.and_then(nonempty),
send_default_pii,
})
}

View file

@ -10,7 +10,8 @@ pub const H3_REQUEST_MAX: u8 = 12;
pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
pub const GRID_CELL_SIZE: f32 = 0.01;
pub const MAX_POIS_PER_REQUEST: usize = 10000;
pub const MAX_CELLS_PER_REQUEST: usize = 200000;
pub const MAX_POIS_PER_REQUEST: usize = 3000;
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;

View file

@ -1,3 +1,4 @@
mod actual_listings;
mod places;
mod poi;
mod postcodes;
@ -33,6 +34,7 @@ where
})
}
pub use actual_listings::{ActualListing, ActualListingData};
pub use places::{normalize_search_text, PlaceData};
pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData};
pub use postcodes::{OutcodeData, PostcodeData};

View file

@ -0,0 +1,326 @@
use std::path::Path;
use anyhow::{Context, Result};
use polars::lazy::frame::LazyFrame;
use polars::prelude::*;
use serde::Serialize;
use tracing::info;
use crate::utils::{normalize_postcode, GridIndex, InternedColumn};
const GRID_CELL_SIZE: f32 = 0.01;
#[derive(Serialize, Clone)]
pub struct ActualListing {
pub lat: f32,
pub lon: f32,
pub postcode: String,
pub address: Option<String>,
pub property_type: Option<String>,
pub property_sub_type: Option<String>,
pub leasehold_freehold: Option<String>,
pub price_qualifier: Option<String>,
pub bedrooms: Option<i32>,
pub bathrooms: Option<i32>,
pub rooms_total: Option<i32>,
pub floor_area_sqm: Option<f32>,
pub asking_price: Option<i64>,
pub asking_price_per_sqm: Option<f32>,
pub listing_url: String,
pub listing_status: Option<String>,
pub listing_date_iso: Option<String>,
pub features: Vec<String>,
}
pub struct ActualListingData {
pub lat: Vec<f32>,
pub lon: Vec<f32>,
/// Normalized (uppercase, canonical spacing) postcode per row.
pub postcode: Vec<String>,
pub address: Vec<Option<String>>,
pub property_type: InternedColumn,
pub property_sub_type: InternedColumn,
pub leasehold_freehold: InternedColumn,
pub price_qualifier: InternedColumn,
pub bedrooms: Vec<Option<i32>>,
pub bathrooms: Vec<Option<i32>>,
pub rooms_total: Vec<Option<i32>>,
pub floor_area_sqm: Vec<Option<f32>>,
pub asking_price: Vec<Option<i64>>,
pub asking_price_per_sqm: Vec<Option<f32>>,
pub listing_url: Vec<String>,
pub listing_status: InternedColumn,
pub listing_date_iso: Vec<Option<String>>,
pub features: Vec<Vec<String>>,
pub grid: GridIndex,
}
impl ActualListingData {
pub fn load(parquet_path: &Path) -> Result<Self> {
super::run_polars_io(|| Self::load_inner(parquet_path))
}
fn load_inner(parquet_path: &Path) -> Result<Self> {
info!("Loading actual listings from {:?}", parquet_path);
let pl_path = PlRefPath::try_from_path(parquet_path)
.context("Failed to normalize actual listings parquet path")?;
let df = LazyFrame::scan_parquet(pl_path, Default::default())
.context("Failed to scan actual listings parquet")?
.collect()
.context("Failed to read actual listings parquet")?;
let row_count = df.height();
info!(rows = row_count, "Actual listings parquet read");
let lat = extract_f32(&df, "lat")?;
let lon = extract_f32(&df, "lon")?;
let postcode_raw = extract_str(&df, "Postcode")?;
let address = extract_opt_str(&df, "Address per Property Register")?;
let property_type_raw = extract_opt_str(&df, "Property type")?;
let property_sub_type_raw = extract_opt_str(&df, "Property sub-type")?;
let leasehold_freehold_raw = extract_opt_str(&df, "Leasehold/Freehold")?;
let price_qualifier_raw = extract_opt_str(&df, "Price qualifier")?;
let bedrooms = extract_opt_i32(&df, "Bedrooms")?;
let bathrooms = extract_opt_i32(&df, "Bathrooms")?;
let rooms_total = extract_opt_i32(&df, "Number of bedrooms & living rooms")?;
let floor_area_sqm = extract_opt_f32(&df, "Total floor area (sqm)")?;
let asking_price = extract_opt_i64(&df, "Asking price")?;
let asking_price_per_sqm = extract_opt_f32(&df, "Asking price per sqm")?;
let listing_url = extract_str(&df, "Listing URL")?;
let listing_status_raw = extract_opt_str(&df, "Listing status")?;
let listing_date_iso = extract_opt_datetime_iso(&df, "Listing date")?;
let features = extract_str_list(&df, "Listing features")?;
let postcode: Vec<String> = postcode_raw.iter().map(|s| normalize_postcode(s)).collect();
let property_type = InternedColumn::build(&opt_to_string(&property_type_raw));
let property_sub_type = InternedColumn::build(&opt_to_string(&property_sub_type_raw));
let leasehold_freehold = InternedColumn::build(&opt_to_string(&leasehold_freehold_raw));
let price_qualifier = InternedColumn::build(&opt_to_string(&price_qualifier_raw));
let listing_status = InternedColumn::build(&opt_to_string(&listing_status_raw));
let grid = GridIndex::build(&lat, &lon, GRID_CELL_SIZE);
info!(rows = row_count, "Actual listings loaded");
Ok(Self {
lat,
lon,
postcode,
address,
property_type,
property_sub_type,
leasehold_freehold,
price_qualifier,
bedrooms,
bathrooms,
rooms_total,
floor_area_sqm,
asking_price,
asking_price_per_sqm,
listing_url,
listing_status,
listing_date_iso,
features,
grid,
})
}
pub fn listing_at(&self, row: usize) -> ActualListing {
ActualListing {
lat: self.lat[row],
lon: self.lon[row],
postcode: self.postcode[row].clone(),
address: self.address[row].clone(),
property_type: opt_from_interned(&self.property_type, row),
property_sub_type: opt_from_interned(&self.property_sub_type, row),
leasehold_freehold: opt_from_interned(&self.leasehold_freehold, row),
price_qualifier: opt_from_interned(&self.price_qualifier, row),
bedrooms: self.bedrooms[row],
bathrooms: self.bathrooms[row],
rooms_total: self.rooms_total[row],
floor_area_sqm: self.floor_area_sqm[row],
asking_price: self.asking_price[row],
asking_price_per_sqm: self.asking_price_per_sqm[row],
listing_url: self.listing_url[row].clone(),
listing_status: opt_from_interned(&self.listing_status, row),
listing_date_iso: self.listing_date_iso[row].clone(),
features: self.features[row].clone(),
}
}
}
fn opt_to_string(values: &[Option<String>]) -> Vec<String> {
values
.iter()
.map(|value| value.clone().unwrap_or_default())
.collect()
}
fn opt_from_interned(column: &InternedColumn, row: usize) -> Option<String> {
let value = column.get(row);
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn extract_f32(df: &DataFrame, name: &str) -> Result<Vec<f32>> {
let cast = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?
.cast(&DataType::Float32)
.with_context(|| format!("Failed to cast '{name}' to Float32"))?;
let column = cast
.f32()
.with_context(|| format!("Column '{name}' is not Float32"))?;
column
.into_iter()
.enumerate()
.map(|(row, value)| value.with_context(|| format!("Column '{name}' has null at row {row}")))
.collect()
}
fn extract_str(df: &DataFrame, name: &str) -> Result<Vec<String>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?;
let strings = column
.str()
.with_context(|| format!("Column '{name}' is not a string column"))?;
strings
.into_iter()
.enumerate()
.map(|(row, value)| {
value
.map(ToString::to_string)
.with_context(|| format!("Column '{name}' has null at row {row}"))
})
.collect()
}
fn extract_opt_str(df: &DataFrame, name: &str) -> Result<Vec<Option<String>>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?;
let strings = column
.str()
.with_context(|| format!("Column '{name}' is not a string column"))?;
Ok(strings
.into_iter()
.map(|value| value.and_then(|text| (!text.is_empty()).then(|| text.to_string())))
.collect())
}
fn extract_opt_i32(df: &DataFrame, name: &str) -> Result<Vec<Option<i32>>> {
let cast = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?
.cast(&DataType::Int32)
.with_context(|| format!("Failed to cast '{name}' to Int32"))?;
let column = cast
.i32()
.with_context(|| format!("Column '{name}' is not Int32"))?;
Ok(column.into_iter().collect())
}
fn extract_opt_i64(df: &DataFrame, name: &str) -> Result<Vec<Option<i64>>> {
let cast = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?
.cast(&DataType::Int64)
.with_context(|| format!("Failed to cast '{name}' to Int64"))?;
let column = cast
.i64()
.with_context(|| format!("Column '{name}' is not Int64"))?;
Ok(column.into_iter().collect())
}
fn extract_opt_f32(df: &DataFrame, name: &str) -> Result<Vec<Option<f32>>> {
let cast = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?
.cast(&DataType::Float32)
.with_context(|| format!("Failed to cast '{name}' to Float32"))?;
let column = cast
.f32()
.with_context(|| format!("Column '{name}' is not Float32"))?;
Ok(column
.into_iter()
.map(|value| value.filter(|v| v.is_finite()))
.collect())
}
fn extract_opt_datetime_iso(df: &DataFrame, name: &str) -> Result<Vec<Option<String>>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?;
let cast = column
.cast(&DataType::Datetime(TimeUnit::Microseconds, None))
.with_context(|| format!("Failed to cast '{name}' to Datetime(us)"))?;
let datetime = cast
.datetime()
.with_context(|| format!("Column '{name}' is not a Datetime column"))?;
Ok(datetime
.as_datetime_iter()
.map(|value| value.map(|date| date.format("%Y-%m-%dT%H:%M:%SZ").to_string()))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn sample_path() -> Option<PathBuf> {
let path = PathBuf::from("../finder/data/online_listings_buy.parquet");
path.exists().then_some(path)
}
#[test]
fn loads_sample_listings_when_available() {
let Some(path) = sample_path() else {
eprintln!("sample parquet not present; skipping");
return;
};
let data = ActualListingData::load(&path).expect("listings load");
assert!(!data.lat.is_empty());
assert_eq!(data.lat.len(), data.lon.len());
assert_eq!(data.lat.len(), data.postcode.len());
assert_eq!(data.lat.len(), data.listing_url.len());
assert_eq!(data.lat.len(), data.features.len());
let any_listing = data.listing_at(0);
assert!(any_listing.lat.is_finite());
assert!(any_listing.lon.is_finite());
assert!(!any_listing.listing_url.is_empty());
}
}
fn extract_str_list(df: &DataFrame, name: &str) -> Result<Vec<Vec<String>>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?;
let list = column
.list()
.with_context(|| format!("Column '{name}' is not a list column"))?;
let mut out = Vec::with_capacity(list.len());
for series_opt in list.into_iter() {
let entries = match series_opt {
Some(series) => {
let strings = series.str().with_context(|| {
format!("Column '{name}' list inner is not a string column")
})?;
strings
.into_iter()
.filter_map(|value| value.map(ToString::to_string))
.collect()
}
None => Vec::new(),
};
out.push(entries);
}
Ok(out)
}

View file

@ -21,6 +21,22 @@ pub struct PlaceData {
pub travel_destination: Vec<bool>,
}
#[derive(Clone, Copy)]
pub(super) struct CityCandidate<'a> {
name: &'a str,
lat: f32,
lon: f32,
population: u32,
max_dist_sq: f32,
}
const PARENT_CITY_MAX_DIST_SQ: f32 = 0.81;
const LONDON_DISPLAY_MAX_DEGREES: f32 = 30.0 / 111.0;
const LONDON_DISPLAY_MAX_DIST_SQ: f32 = LONDON_DISPLAY_MAX_DEGREES * LONDON_DISPLAY_MAX_DEGREES;
const SUBSUMED_CITY_MAX_DEGREES: f32 = 5.0 / 111.0;
const SUBSUMED_CITY_MAX_DIST_SQ: f32 = SUBSUMED_CITY_MAX_DEGREES * SUBSUMED_CITY_MAX_DEGREES;
const SUBSUMED_CITY_MIN_POPULATION_RATIO: u32 = 10;
fn type_rank(place_type: &str) -> u8 {
match place_type {
"city" => 0,
@ -37,6 +53,96 @@ pub fn is_travel_destination_type(place_type: &str) -> bool {
matches!(place_type, "city" | "station" | "university")
}
impl<'a> CityCandidate<'a> {
fn from_place(name: &'a str, lat: f32, lon: f32, population: u32) -> Self {
let max_dist_sq = if name == "London" {
LONDON_DISPLAY_MAX_DIST_SQ
} else {
PARENT_CITY_MAX_DIST_SQ
};
Self {
name,
lat,
lon,
population,
max_dist_sq,
}
}
fn distance_sq(&self, lat: f32, lon: f32, cos_lat: f32) -> f32 {
let dlat = self.lat - lat;
let dlon = (self.lon - lon) * cos_lat;
dlat * dlat + dlon * dlon
}
fn is_subsumed_by(&self, other: &Self) -> bool {
if self.population == 0 {
return false;
}
let min_parent_population =
u64::from(self.population) * u64::from(SUBSUMED_CITY_MIN_POPULATION_RATIO);
if u64::from(other.population) < min_parent_population {
return false;
}
other.distance_sq(self.lat, self.lon, self.lat.to_radians().cos())
< SUBSUMED_CITY_MAX_DIST_SQ
}
}
pub(super) fn display_city_candidates<'a>(
names: &'a [String],
type_rank: &[u8],
population: &[u32],
lat: &[f32],
lon: &[f32],
) -> Vec<CityCandidate<'a>> {
let cities: Vec<CityCandidate<'_>> = type_rank
.iter()
.enumerate()
.filter_map(|(idx, &rank)| {
if rank == 0 {
Some(CityCandidate::from_place(
&names[idx],
lat[idx],
lon[idx],
population[idx],
))
} else {
None
}
})
.collect();
cities
.iter()
.enumerate()
.filter_map(|(idx, city)| {
let is_subsumed = cities
.iter()
.enumerate()
.any(|(other_idx, other)| other_idx != idx && city.is_subsumed_by(other));
(!is_subsumed).then_some(*city)
})
.collect()
}
pub(super) fn nearest_display_city<'a>(
lat: f32,
lon: f32,
cities: &'a [CityCandidate<'a>],
) -> Option<&'a str> {
let cos_lat = lat.to_radians().cos();
let (best_city, best_dist_sq) = cities
.iter()
.map(|city| (city, city.distance_sq(lat, lon, cos_lat)))
.min_by(|(_, lhs), (_, rhs)| lhs.total_cmp(rhs))?;
(best_dist_sq < best_city.max_dist_sq).then_some(best_city.name)
}
pub fn normalize_search_text(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut last_was_space = true;
@ -182,6 +288,25 @@ fn extract_bool_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<bool>> {
.collect()
}
fn extract_optional_str_col(
df: &DataFrame,
name: &str,
) -> anyhow::Result<Option<Vec<Option<String>>>> {
let column = match df.column(name) {
Ok(column) => column,
Err(_) => return Ok(None),
};
let string_column = column
.str()
.with_context(|| format!("Column '{name}' is not a string column"))?;
Ok(Some(
string_column
.into_iter()
.map(|value| value.map(ToString::to_string))
.collect(),
))
}
impl PlaceData {
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
super::run_polars_io(|| Self::load_inner(parquet_path))
@ -227,44 +352,42 @@ impl PlaceData {
.map(|place_type| is_travel_destination_type(place_type))
.collect()
};
let display_city_override = extract_optional_str_col(&df, "display_city")?;
// Precompute nearest city for each non-city place
let city_indices: Vec<usize> = type_rank_vec
.iter()
.enumerate()
.filter_map(|(idx, &rank)| if rank == 0 { Some(idx) } else { None })
.collect();
let city_candidates =
display_city_candidates(&name, &type_rank_vec, &population, &lat, &lon);
let city: Vec<Option<String>> = (0..row_count)
let fallback_city: Vec<Option<String>> = (0..row_count)
.map(|idx| {
if type_rank_vec[idx] == 0 {
return None; // Cities don't need a city label
}
let plat = lat[idx];
let plon = lon[idx];
let cos_lat = (plat.to_radians()).cos();
let mut best_dist_sq = f32::MAX;
let mut best_city: Option<&str> = None;
for &ci in &city_indices {
let dlat = lat[ci] - plat;
let dlon = (lon[ci] - plon) * cos_lat;
let dist_sq = dlat * dlat + dlon * dlon;
if dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best_city = Some(&name[ci]);
}
}
// ~100km threshold: 1° ≈ 111km, so 0.9° ≈ 100km → 0.81 squared
if best_dist_sq < 0.81 {
best_city.map(|s| s.to_string())
} else {
None
}
nearest_display_city(lat[idx], lon[idx], &city_candidates).map(str::to_string)
})
.collect();
let city: Vec<Option<String>> = if let Some(display_city_override) = display_city_override {
fallback_city
.into_iter()
.zip(display_city_override)
.enumerate()
.map(|(idx, (fallback, override_city))| {
if type_rank_vec[idx] == 0 {
return None;
}
override_city
.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
.or(fallback)
})
.collect()
} else {
fallback_city
};
let with_pop = population.iter().filter(|&&pop| pop > 0).count();
let with_city = city.iter().filter(|c| c.is_some()).count();
info!(
@ -294,6 +417,41 @@ impl PlaceData {
mod tests {
use super::*;
fn test_city_rows() -> [(&'static str, f32, f32, u32); 5] {
[
("London", 51.5074456, -0.1277653, 8_908_083),
("Westminster", 51.4973206, -0.137149, 211_365),
("City of London", 51.5156177, -0.0919983, 10_847),
("Cambridge", 52.2055314, 0.1186637, 145_818),
("Oxford", 51.7520131, -1.2578499, 165_000),
]
}
fn all_test_city_candidates() -> Vec<CityCandidate<'static>> {
test_city_rows()
.into_iter()
.map(|(name, lat, lon, population)| {
CityCandidate::from_place(name, lat, lon, population)
})
.collect()
}
fn test_city_candidates() -> Vec<CityCandidate<'static>> {
let cities = all_test_city_candidates();
cities
.iter()
.enumerate()
.filter_map(|(idx, city)| {
let is_subsumed = cities
.iter()
.enumerate()
.any(|(other_idx, other)| other_idx != idx && city.is_subsumed_by(other));
(!is_subsumed).then_some(*city)
})
.collect()
}
#[test]
fn type_rank_ordering() {
assert!(type_rank("city") < type_rank("town"));
@ -316,4 +474,64 @@ mod tests {
assert!(!is_travel_destination_type("town"));
assert!(!is_travel_destination_type("suburb"));
}
#[test]
fn display_city_candidates_drop_city_nodes_subsumed_by_much_larger_nearby_city() {
let rows = test_city_rows();
let names: Vec<String> = rows
.iter()
.map(|(name, _, _, _)| name.to_string())
.collect();
let type_rank: Vec<u8> = vec![0; rows.len()];
let population: Vec<u32> = rows
.iter()
.map(|(_, _, _, population)| *population)
.collect();
let lat: Vec<f32> = rows.iter().map(|(_, lat, _, _)| *lat).collect();
let lon: Vec<f32> = rows.iter().map(|(_, _, lon, _)| *lon).collect();
let cities = display_city_candidates(&names, &type_rank, &population, &lat, &lon);
assert_eq!(
cities.iter().map(|city| city.name).collect::<Vec<_>>(),
["London", "Cambridge", "Oxford"]
);
}
#[test]
fn nearest_display_city_labels_inner_greater_london_from_london_candidate() {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(51.3713049, -0.101957, &cities),
Some("London")
);
}
#[test]
fn nearest_display_city_preserves_non_london_duplicates() {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(52.1277704, -0.0813098, &cities),
Some("Cambridge")
);
}
#[test]
fn nearest_display_city_does_not_extend_london_past_its_display_radius() {
let cities = test_city_candidates();
assert_eq!(nearest_display_city(51.5093, -0.5954, &cities), None);
}
#[test]
fn nearest_display_city_keeps_normal_non_london_city() {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(51.456659, -0.969651, &cities),
Some("Oxford")
);
}
}

View file

@ -6,6 +6,7 @@ use std::fs;
use std::path::Path;
use tracing::{debug, info};
use super::places::{display_city_candidates, nearest_display_city};
use super::PlaceData;
/// Precomputed outcode data derived from postcode boundaries.
@ -52,35 +53,17 @@ impl OutcodeData {
let centroids: Vec<(f32, f32)> = entries.iter().map(|(_, c)| *c).collect();
// Compute nearest city for each outcode (same algorithm as PlaceData)
let city_indices: Vec<usize> = place_data
.type_rank
.iter()
.enumerate()
.filter_map(|(idx, &rank)| if rank == 0 { Some(idx) } else { None })
.collect();
let city_candidates = display_city_candidates(
&place_data.name,
&place_data.type_rank,
&place_data.population,
&place_data.lat,
&place_data.lon,
);
let cities: Vec<Option<String>> = centroids
.iter()
.map(|&(lat, lon)| {
let cos_lat = lat.to_radians().cos();
let mut best_dist_sq = f32::MAX;
let mut best_city: Option<&str> = None;
for &ci in &city_indices {
let dlat = place_data.lat[ci] - lat;
let dlon = (place_data.lon[ci] - lon) * cos_lat;
let dist_sq = dlat * dlat + dlon * dlon;
if dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best_city = Some(&place_data.name[ci]);
}
}
// ~100km threshold
if best_dist_sq < 0.81 {
best_city.map(|s| s.to_string())
} else {
None
}
})
.map(|&(lat, lon)| nearest_display_city(lat, lon, &city_candidates).map(str::to_string))
.collect();
info!(

View file

@ -160,6 +160,11 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
],
},
FeatureGroup {
name: "Defining characteristics",
features: &[
Feature::Numeric(FeatureConfig {
name: "Street tree density percentile",
bounds: Bounds::Fixed {
@ -175,6 +180,21 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: true,
}),
Feature::Numeric(FeatureConfig {
name: "Noise (dB)",
bounds: Bounds::Fixed {
min: 50.0,
max: 80.0,
},
step: 1.0,
description: "Maximum transport noise level near the postcode in decibels (Lden)",
detail: "Maximum road, rail, or airport noise level in decibels (Lden, a 24-hour weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.",
source: "noise",
prefix: "",
suffix: " dB",
raw: false,
absolute: false,
}),
],
},
FeatureGroup {
@ -270,7 +290,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
],
},
FeatureGroup {
name: "Education",
name: "Schools",
features: &[
Feature::Numeric(FeatureConfig {
name: "Good+ primary schools within 2km",
@ -983,21 +1003,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
FeatureGroup {
name: "Amenities",
features: &[
Feature::Numeric(FeatureConfig {
name: "Noise (dB)",
bounds: Bounds::Fixed {
min: 50.0,
max: 80.0,
},
step: 1.0,
description: "Maximum transport noise level near the postcode in decibels (Lden)",
detail: "Maximum road, rail, or airport noise level in decibels (Lden, a 24-hour weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.",
source: "noise",
prefix: "",
suffix: " dB",
raw: false,
absolute: false,
}),
Feature::Enum(EnumFeatureConfig {
name: "Max available download speed (Mbps)",
order: Some(&["10", "30", "100", "300", "1000"]),

View file

@ -2,6 +2,7 @@
mod aggregation;
mod auth;
mod bugsink;
mod checkout_sessions;
mod consts;
mod data;
@ -29,6 +30,7 @@ use axum::Router;
use clap::Parser;
use consts::SERVICE_CALL_TIMEOUT;
use tower::limit::ConcurrencyLimitLayer;
use tower::ServiceBuilder;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{AllowHeaders, AllowMethods, CorsLayer};
@ -200,6 +202,10 @@ struct Cli {
#[arg(long, env = "TRAVEL_TIMES")]
travel_times: PathBuf,
/// Optional path to a parquet of live online listings (Rightmove etc.) to overlay on the map.
#[arg(long, env = "ACTUAL_LISTINGS_PATH")]
actual_listings_path: Option<PathBuf>,
/// Google Maps API key for Street View metadata lookups
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
google_maps_api_key: String,
@ -216,14 +222,6 @@ struct Cli {
#[arg(long, env = "STRIPE_REFERRAL_COUPON_ID")]
stripe_referral_coupon_id: String,
/// Bearer token required to scrape /metrics.
#[arg(long, env = "METRICS_BEARER_TOKEN")]
metrics_bearer_token: Option<String>,
/// Allow unauthenticated /metrics scraping when no METRICS_BEARER_TOKEN is set.
#[arg(long, env = "ALLOW_PUBLIC_METRICS", default_value_t = false)]
allow_public_metrics: bool,
/// Google OAuth client ID for PocketBase SSO
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_ID")]
google_oauth_client_id: String,
@ -231,10 +229,87 @@ struct Cli {
/// Google OAuth client secret for PocketBase SSO
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_SECRET")]
google_oauth_client_secret: String,
/// Bugsink DSN for backend error reporting
#[arg(long, env = "BUGSINK_DSN", hide_env_values = true)]
bugsink_dsn: Option<String>,
/// Bugsink DSN injected into the browser app; falls back to BUGSINK_DSN when omitted
#[arg(long, env = "FRONTEND_BUGSINK_DSN", hide_env_values = true)]
frontend_bugsink_dsn: Option<String>,
/// Bugsink/Sentry environment name
#[arg(long, env = "BUGSINK_ENVIRONMENT")]
bugsink_environment: Option<String>,
/// Bugsink/Sentry release name
#[arg(long, env = "BUGSINK_RELEASE")]
bugsink_release: Option<String>,
/// Include default PII in Bugsink events
#[arg(long, env = "BUGSINK_SEND_DEFAULT_PII", default_value_t = false)]
bugsink_send_default_pii: bool,
}
async fn capture_server_error_responses(
request: axum::extract::Request,
next: middleware::Next,
) -> axum::response::Response {
let method = request.method().clone();
let path = request.uri().path().to_owned();
let response = next.run(request).await;
let status = response.status();
if status.is_server_error() {
sentry::with_scope(
|scope| {
scope.set_tag("http.status_code", status.as_u16().to_string());
scope.set_tag("http.method", method.to_string());
scope.set_tag("http.route", path.clone());
},
|| {
sentry::capture_message(
&format!("HTTP {status} response from {method} {path}"),
sentry::Level::Error,
);
},
);
}
response
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let bugsink_environment = cli
.bugsink_environment
.clone()
.or_else(|| bugsink::env_nonempty("SENTRY_ENVIRONMENT"));
let bugsink_release = cli
.bugsink_release
.clone()
.or_else(|| bugsink::env_nonempty("SENTRY_RELEASE"));
let backend_bugsink_dsn = cli
.bugsink_dsn
.clone()
.or_else(|| bugsink::env_nonempty("SENTRY_DSN"));
let _bugsink_guard = bugsink::init_backend(&bugsink::BackendConfig {
dsn: backend_bugsink_dsn.clone(),
environment: bugsink_environment.clone(),
release: bugsink_release.clone(),
send_default_pii: cli.bugsink_send_default_pii,
});
let bugsink_frontend_config = bugsink::frontend_config(
cli.frontend_bugsink_dsn
.clone()
.or_else(|| bugsink::env_nonempty("PUBLIC_BUGSINK_DSN"))
.or(backend_bugsink_dsn),
bugsink_environment.clone(),
bugsink_release.clone(),
cli.bugsink_send_default_pii,
);
let file_appender = tracing_appender::rolling::daily("logs", "server.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
@ -242,6 +317,7 @@ async fn main() -> anyhow::Result<()> {
tracing_subscriber::registry()
.with(env_filter)
.with(sentry::integrations::tracing::layer())
.with(tracing_subscriber::fmt::layer().with_ansi(true))
.with(
tracing_subscriber::fmt::layer()
@ -253,10 +329,9 @@ async fn main() -> anyhow::Result<()> {
// Initialize Prometheus metrics
let metrics_handle = metrics::init_metrics();
info!("Prometheus metrics initialized");
let cli = Cli::parse();
let metrics_bearer_token = cli.metrics_bearer_token.clone();
let allow_public_metrics = cli.allow_public_metrics;
if bugsink_frontend_config.is_some() || _bugsink_guard.is_some() {
info!("Bugsink error reporting configured");
}
for (label, path) in [
("Properties", &cli.properties),
@ -460,6 +535,20 @@ async fn main() -> anyhow::Result<()> {
let superuser_token_cache = Arc::new(pocketbase::SuperuserTokenCache::new());
let share_cache = Arc::new(licensing::ShareBoundsCache::new());
let actual_listings = if let Some(path) = cli.actual_listings_path.as_ref() {
if !path.exists() {
bail!("Actual listings parquet not found: {}", path.display());
}
info!("Loading actual listings from {}", path.display());
let listings = data::ActualListingData::load(path)?;
trim_allocator("actual listings load");
info!(rows = listings.lat.len(), "Actual listings loaded");
Some(Arc::new(listings))
} else {
info!("ACTUAL_LISTINGS_PATH not set; live listings overlay disabled");
None
};
let app_state = AppState {
data: property_data,
grid,
@ -485,6 +574,7 @@ async fn main() -> anyhow::Result<()> {
gemini_api_key: cli.gemini_api_key,
gemini_model: cli.gemini_model,
travel_time_store,
actual_listings,
token_cache,
superuser_token_cache,
share_cache,
@ -493,6 +583,7 @@ async fn main() -> anyhow::Result<()> {
stripe_secret_key: cli.stripe_secret_key,
stripe_webhook_secret: cli.stripe_webhook_secret,
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
bugsink_frontend_config,
};
let shared = Arc::new(SharedState::new(app_state));
@ -545,6 +636,10 @@ async fn main() -> anyhow::Result<()> {
"/api/pois",
get(routes::get_pois).layer(ConcurrencyLimitLayer::new(20)),
)
.route(
"/api/actual-listings",
get(routes::get_actual_listings).layer(ConcurrencyLimitLayer::new(20)),
)
.route(
"/api/poi-categories",
get(routes::get_poi_categories).layer(ConcurrencyLimitLayer::new(20)),
@ -680,14 +775,7 @@ async fn main() -> anyhow::Result<()> {
.route("/health", get(|| async { "ok" }))
.route(
"/metrics",
get(move |headers| {
metrics::metrics_handler(
metrics_handle.clone(),
metrics_bearer_token.clone(),
allow_public_metrics,
headers,
)
}),
get(move |connect_info| metrics::metrics_handler(metrics_handle.clone(), connect_info)),
)
.with_state(shared.clone());
@ -711,9 +799,17 @@ async fn main() -> anyhow::Result<()> {
},
))
.layer(middleware::from_fn(static_cache_headers))
.layer(middleware::from_fn(capture_server_error_responses))
.layer(cors)
.layer(CompressionLayer::new().zstd(true).gzip(true))
.layer(TraceLayer::new_for_http());
.layer(TraceLayer::new_for_http())
.layer(
ServiceBuilder::new()
.layer(sentry::integrations::tower::NewSentryLayer::<
axum::extract::Request,
>::new_from_top())
.layer(sentry::integrations::tower::SentryHttpLayer::new()),
);
// Lock all current and future memory pages to prevent swapping
unsafe {
@ -732,6 +828,11 @@ async fn main() -> anyhow::Result<()> {
.await
.with_context(|| format!("Failed to bind to {addr}"))?;
info!("Server listening on {}", addr);
axum::serve(listener, app).await.context("Server error")?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.context("Server error")?;
Ok(())
}

Some files were not shown because too many files have changed in this diff Show more