Compare commits

...

4 commits

Author SHA1 Message Date
349a6c1d53 Fun changes
Some checks failed
CI / Python (lint + test) (push) Failing after 3m38s
CI / Rust (lint + test) (push) Failing after 3m32s
CI / Frontend (lint + typecheck) (push) Failing after 4m12s
Build and publish Docker image / build-and-push (push) Failing after 4m48s
2026-04-04 22:59:44 +01:00
cd778dd088 Remove finder 2026-04-04 22:59:28 +01:00
55238f59aa Lint & small changes 2026-04-04 22:59:07 +01:00
0c6d207967 Clean up 2026-04-04 17:44:44 +01:00
116 changed files with 5299 additions and 61761 deletions

View file

@ -23,9 +23,6 @@ jobs:
- name: Download map assets (fonts, sprites, twemoji)
run: uv run python -m pipeline.download.map_assets --output frontend/public/assets
- name: Download arcgis data for finder
run: uv run python -m pipeline.download.arcgis --output property-data/arcgis_data.parquet
- name: Install Docker CLI
run: |
ARCH=$(uname -m)
@ -88,14 +85,3 @@ jobs:
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:buildcache,mode=max
- name: Build and push finder service
uses: https://github.com/docker/build-push-action@v6
with:
context: .
file: Dockerfile.finder
push: true
tags: |
${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-finder:latest
${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-finder:sha-${{ steps.tags.outputs.sha_short }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-finder:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-finder:buildcache,mode=max

View file

@ -27,9 +27,6 @@ jobs:
- name: Download map assets (fonts, sprites, twemoji)
run: uv run python -m pipeline.download.map_assets --output frontend/public/assets
- name: Download arcgis data for finder
run: uv run python -m pipeline.download.arcgis --output property-data/arcgis_data.parquet
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -73,15 +70,3 @@ jobs:
cache-from: type=gha,scope=screenshot
cache-to: type=gha,mode=max,scope=screenshot
- name: Build and push finder service
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.finder
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-finder:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-finder:sha-${{ github.sha }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=finder
cache-to: type=gha,mode=max,scope=finder

View file

@ -98,8 +98,8 @@ Rust + Axum. Loads parquet into memory at startup.
**API endpoints:**
- `GET /api/features` — Feature metadata with histograms and 2nd/98th percentiles
- `GET /api/hexagons?resolution=&bounds=&filters=&fields=` — H3 aggregates (min/max per feature per hex), AABB-filtered to bounds
- `GET /api/postcodes?bounds=&filters=&fields=` — Postcode polygon aggregates, AABB-filtered to bounds
- `GET /api/hexagons?resolution=&bounds=&filters=&fields=&enum_dist=` — H3 aggregates (min/max per feature per hex), AABB-filtered to bounds. Optional `enum_dist=FeatureName` adds `dist_FeatureName: [count_per_value...]` arrays for pie chart visualization.
- `GET /api/postcodes?bounds=&filters=&fields=&enum_dist=` — Postcode polygon aggregates, AABB-filtered to bounds. Same `enum_dist` support as hexagons.
- `GET /api/postcode/:postcode` — Single postcode lookup (centroid + polygon)
- `GET /api/hexagon-properties?h3=&resolution=&filters=&limit=&offset=` — Paginated properties within a hexagon
- `GET /api/postcode-properties?postcode=&filters=&limit=&offset=` — Paginated properties within a postcode
@ -110,7 +110,8 @@ Serves `frontend/dist/` as static fallback in production **only** when `--dist`
**Data representation (unified model):**
- All features (numeric and enum): row-major flat `Vec<f32>`, NaN = null
- Enum features: stored as f32 indices (0.0, 1.0, 2.0...) with `enum_values: FxHashMap<usize, Vec<String>>` mapping feature index → string values
- Enum features: stored as f32 indices (0.0, 1.0, 2.0...) with `enum_values: FxHashMap<usize, Vec<String>>` mapping feature index → string values. Raw u16 indices are used directly for distribution counting (no dequantization needed for enums).
- Enum distribution: `Aggregator` optionally tracks per-value counts via `EnumDist` struct (configured by `EnumDistConfig`). Emitted as `dist_FeatureName: [count_val0, count_val1, ...]` in hex/postcode responses when `enum_dist` param is set.
- String fields (address, postcode): interned/packed for memory efficiency
- All CLI args are required (no hidden defaults). Optional services use `Option<String>`: `r5_url` (travel time disabled when None), `pocketbase_admin_email`/`password` (collection auto-creation skipped when None). Required config like `gemini_model` and `public_url` must be explicitly provided via env or CLI.
@ -161,6 +162,7 @@ React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. N
- `features.ts``groupFeaturesByCategory(features)` groups FeatureMeta[] by their `group` field.
- `format.ts``formatNumber(value, decimals)` for number formatting. `calculateHistogramMean(histogram)` for weighted mean calculation.
- `property-fields.ts``getNum(property, key)` for getting a single numeric property value. Takes exactly one key — no fallback names.
- `PieHexExtension.ts` — deck.gl `LayerExtension` that turns polygon fills into hexagonal pie charts. Injects GLSL that computes angle from fragment position to centroid, picks slice color from ENUM_PALETTE. See "deck.gl LayerExtension patterns" below.
When adding new UI, prefer using these shared components over inline implementations to maintain consistency.
@ -171,6 +173,21 @@ When adding new UI, prefer using these shared components over inline implementat
- Extract to `lib/`: Pure functions used across components (formatting, calculations, lookups)
- Keep inline: One-off UI specific to a single component
**deck.gl LayerExtension patterns (CRITICAL — hard-won knowledge):**
Creating custom `LayerExtension`s that add per-instance attributes to CompositeLayer sublayers (H3HexagonLayer, PolygonLayer, GeoJsonLayer) requires following the exact canonical pattern. Getting any part wrong silently fails (attributes read as zero).
1. **`static defaultProps` with `type: 'accessor'`** — This is what tells `LayerExtension.getSubLayerProps()` to wrap accessors via `getSubLayerAccessor()`, which unwraps `__source.object` to reach the original data item through CompositeLayer sublayer chains. Without this, accessors receive `undefined` or binary data objects instead of the original data.
2. **`stepMode: 'dynamic'`** instead of `addInstanced()` — Use `am.add({...})` with `stepMode: 'dynamic'`, not `am.addInstanced({...})`. Dynamic step mode handles per-instance counting automatically for variable-geometry layers like SolidPolygonLayer.
3. **`isEnabled(layer)` must guard all hooks** — Check in `getShaders()` and `initializeState()`. For polygon fills, use `layer.id.endsWith('-fill')` to skip PathLayer (stroke) sublayers.
4. **Change layer ID when extensions change** — deck.gl recycles layers with the same ID. If you conditionally add/remove extensions, use a different layer ID (e.g., `'h3-hexagons-pie'` vs `'h3-hexagons'`) to force full teardown/rebuild. Otherwise `initializeState` never re-runs and attributes are never populated.
5. **Include `data` in updateTriggers for extension accessors** — When API data changes (e.g., new response with `dist_` fields), `colorTrigger` may not change. Include the `data` array reference in the extension accessor updateTriggers so the attribute manager re-runs the accessors on fresh data.
6. **FragmentGeometry only has `uv`** — In deck.gl v9's fragment shader, `geometry.position` does NOT exist. The `VertexGeometry` struct has `position`, `worldPosition`, `normal`, etc., but `FragmentGeometry` only has `uv`. To get fragment position in the FS, capture `geometry.position.xy` in the VS into a custom varying.
7. **Binary attribute overrides go in `data.attributes`** — In deck.gl v9, `props.instanceFoo` is rejected with "has been removed". Use `data.attributes.instanceFoo` instead. But for extensions using the accessor pattern above, this isn't needed.
8. **`getSubLayerProps` only forwards whitelisted props** — Custom props (binary buffers, accessors) set on a CompositeLayer are NOT automatically forwarded to sublayers. The `defaultProps` + `getSubLayerProps()` mechanism in step 1 is the ONLY reliable way to get extension data through the chain.
See `PieHexExtension.ts` for a working example and `DataFilterExtension` / `FillStyleExtension` in `@deck.gl/extensions` for reference implementations.
**Component size guideline:** If a component exceeds ~300 lines, look for extraction opportunities. Large components are usually doing too much — split into hooks (for logic) and child components (for UI sections).
**Naming conventions:**
@ -376,7 +393,7 @@ Follow these conventions in all Rust code:
- **POI transform validation**: Fails if any OSM category is unmapped — guarantees exhaustive coverage
- **Fuzzy join**: Groups by postcode, uses `thefuzz.token_sort_ratio` with numeric token compatibility, greedy assignment from highest score
- **Filter parsing is strict**: `parse_filters()` returns `Result` — malformed entries, unknown feature names, and unparseable numbers all return 400 Bad Request. No silent skipping of invalid filters.
- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. All configured features (defined in `features.rs`) must exist in at least one data source — the server panics at startup if any are missing (no NaN placeholders). This means all pipeline steps must be complete before starting the server. Polars `diagonal: true` concat fills nulls for features that exist in some but not all sources (e.g. "Listing date" from listings only).
- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. All configured features (defined in `features.rs`) must exist in the data — the server panics at startup if any are missing (no NaN placeholders). This means all pipeline steps must be complete before starting the server.
- **Travel time is strict**: `mode` param is required (400) when `destination` is set — no silent default to "car". R5 failures return 502 Bad Gateway, not silent omission. `r5_url` is `Option<String>` — returns 503 if travel time requested without R5 configured.
- **Filter bounds format**: `south,west,north,east` (not standard bbox order)
- **Server-side AABB filtering**: Both `/api/hexagons` and `/api/postcodes` filter results by bounding-box intersection with query bounds. Hexagons use `h3_cell_bounds()` (h3o returns degrees, not radians). Postcodes compute polygon AABB from vertices. See `bounds_intersect()` in `parsing/bounds.rs`.
@ -384,6 +401,7 @@ Follow these conventions in all Rust code:
- **GridIndex returns slightly more than requested**: The 0.01° grid cells mean properties up to ~1km outside the viewport may be returned. The AABB filter in the route handlers catches these extras.
- **POI proximity**: Uses 0.05° grid (~5km cells) to reduce candidates before haversine distance check
- **OG tag injection**: Uses `<meta name="x-og-placeholder" content="__PERFECT_POSTCODES_OG_TAGS__"/>` placeholder in HTML, replaced at runtime by middleware
- **Enum distribution (pie charts)**: When `enum_dist=FeatureName` is set on `/api/hexagons` or `/api/postcodes`, each cell includes `dist_FeatureName: [count_for_val0, count_for_val1, ...]`. The `Aggregator` struct has optional `EnumDist` that counts raw u16 enum indices per cell. `parse_enum_dist()` in `parsing/fields.rs` validates the feature name and confirms it's an enum. On the frontend, `PieHexExtension` (LayerExtension) injects GLSL into SolidPolygonLayer's fragment shader: computes angle from fragment position to hex centroid (passed as `instancePieCenter` varying), picks slice color from ENUM_PALETTE. `useMapData` adds the `enum_dist` query param when `viewFeatureIsEnum` is true.
- **Dev invite code**: The code `devdevdevdev` is recognized as a valid admin invite in dev mode only (`state.index_html.is_none()`, i.e., `--dist` not passed). Both `get_invite` and `post_redeem_invite` short-circuit for this code, returning a fake valid admin invite / no-op "licensed" response without hitting PocketBase. Preview at `http://localhost:3001/invite/devdevdevdev`.
## Rust Performance Patterns (server-rs)

View file

@ -24,8 +24,7 @@ COPY --from=frontend /app/frontend/dist ./frontend/dist/
# Data is provided via volume mounts at runtime — not baked into the image.
# Mount points:
# /app/data - properties.parquet, postcode.parquet, filtered_uk_pois.parquet, places.parquet, uk.pmtiles, postcode_boundaries/, travel-times/
# /app/data-scraped - online_listings_buy.parquet, online_listings_rent.parquet
VOLUME ["/app/data", "/app/data-scraped"]
VOLUME ["/app/data"]
RUN chown -R appuser:appuser /app
USER appuser
@ -33,4 +32,4 @@ EXPOSE 8001
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
CMD curl -f http://localhost:8001/health || exit 1
ENTRYPOINT ["./property-map-server"]
CMD ["--properties", "/app/data/properties.parquet", "--postcode-features", "/app/data/postcode.parquet", "--listings-buy", "/app/data-scraped/online_listings_buy.parquet", "--listings-rent", "/app/data-scraped/online_listings_rent.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", "--dist", "/app/frontend/dist"]
CMD ["--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", "--dist", "/app/frontend/dist"]

View file

@ -1,16 +0,0 @@
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY finder/pyproject.toml ./
RUN uv pip install --system -r pyproject.toml
RUN playwright install-deps chromium firefox
RUN playwright install chromium
RUN camoufox fetch \
&& python -c "from camoufox.pkgman import camoufox_path; p = camoufox_path(download_if_missing=False); print('Camoufox verified at', p)"
COPY finder/*.py ./
COPY property-data/arcgis_data.parquet /data/arcgis_data.parquet
CMD ["python3", "main.py"]

View file

@ -47,10 +47,9 @@ GREENSPACE := $(DATA_DIR)/greenspace_water.parquet
OS_GREENSPACE := $(DATA_DIR)/os_greenspace.parquet
PBF := $(DATA_DIR)/england-latest.osm.pbf
PLACES := $(DATA_DIR)/places.parquet
LISTINGS_BUY := $(DATA_DIR)/online_listings_buy.parquet
LISTINGS_RENT := $(DATA_DIR)/online_listings_rent.parquet
LSOA_POP := $(DATA_DIR)/lsoa_population.parquet
MEDIAN_AGE := $(DATA_DIR)/median_age.parquet
ELECTION := $(DATA_DIR)/election_results.parquet
ENGLAND_BOUNDARY := $(DATA_DIR)/england_boundary.geojson
RM_OUTCODES := frontend/src/lib/rightmove-outcodes.json
@ -94,6 +93,7 @@ download-pbf: $(PBF)
download-places: $(PLACES)
download-lsoa-population: $(LSOA_POP)
download-median-age: $(MEDIAN_AGE)
download-election-results: $(ELECTION)
download-england-boundary: $(ENGLAND_BOUNDARY)
download-rightmove-outcodes: $(RM_OUTCODES)
transform-pois: $(POIS_FILTERED)
@ -193,6 +193,9 @@ $(LSOA_POP):
$(MEDIAN_AGE):
uv run python -m pipeline.download.median_age --output $@
$(ELECTION):
uv run python -m pipeline.download.election_results --output $@
$(ENGLAND_BOUNDARY):
uv run python -m pipeline.download.england_boundary --output $@
@ -242,7 +245,7 @@ $(PC_BOUNDARIES):
# ── Final merge → postcode.parquet + properties.parquet ──────────────────────
$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(RENTAL) $(LSOA_POP) $(MEDIAN_AGE)
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(RENTAL) $(LSOA_POP) $(MEDIAN_AGE) $(ELECTION)
uv run python -m pipeline.transform.merge \
--epc-pp $(EPC_PP) \
--arcgis $(ARCGIS) \
@ -256,6 +259,7 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
--rental-prices $(RENTAL) \
--lsoa-population $(LSOA_POP) \
--median-age $(MEDIAN_AGE) \
--election-results $(ELECTION) \
--output-postcodes $(POSTCODES_PQ) \
--output-properties $(PROPERTIES_PQ)
@touch $@

View file

@ -93,4 +93,3 @@ Test on android
check rendered index html,
only support new finder.py parquet type

View file

@ -10,7 +10,7 @@ services:
command: >
bash -c "
cargo install cargo-watch &&
cargo watch -i logs/ -x 'run -- --properties /app/data/properties.parquet --postcode-features /app/data/postcode.parquet --listings-buy /app/data-scraped/online_listings_buy.parquet --listings-rent /app/data-scraped/online_listings_rent.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'
cargo watch -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"
@ -30,7 +30,6 @@ services:
- cargo-target:/app/server-rs/target
- ./property-data:/app/data:ro
- ./property-data/travel-times:/app/data/travel-times:ro
- /volumes/narrowit/property-data/scraped:/app/data-scraped:ro
environment:
POCKETBASE_URL: http://pocketbase:8090
POCKETBASE_ADMIN_EMAIL: *pb-email
@ -108,75 +107,6 @@ services:
retries: 3
start_period: 5s
# gluetun:
# image: qmcgaw/gluetun:v3.40.4
# volumes:
# - gluetun-cache-v2:/gluetun
# - gluetun-auth:/gluetun/auth:ro
# environment:
# # See https://github.com/qdm12/gluetun-wiki/tree/main/setup#setup
# VPN_SERVICE_PROVIDER: mullvad
# VPN_TYPE: wireguard
# WIREGUARD_PRIVATE_KEY: "8FFKmtTvDsZlShnKl/opDDwCwb9v2ox4+Kkl3wX+9Gw="
# WIREGUARD_ADDRESSES: "10.66.109.86/32"
# OWNED_ONLY: "yes"
# UPDATER_PERIOD: 24h
# SERVER_COUNTRIES: Serbia,Slovakia,Croatia,Austria,Denmark,Finland
# TZ: $TIME_ZONE
# restart: unless-stopped
# ports:
# - "1234:1234"
# healthcheck:
# test: "wget -q https://www.google.com || exit 1"
# interval: 1m
# timeout: 15s
# retries: 2
# cap_add:
# - NET_ADMIN
# devices:
# - /dev/net/tun:/dev/net/tun
# flaresolverr:
# image: ghcr.io/flaresolverr/flaresolverr:latest
# environment:
# LOG_LEVEL: info
# TZ: Europe/London
# ports:
# - "8191:8191"
# networks:
# - dev-network
# restart: unless-stopped
# healthcheck:
# test: ["CMD", "curl", "-f", "http://localhost:8191/health"]
# interval: 30s
# timeout: 5s
# retries: 3
# start_period: 30s
# finder:
# build:
# context: .
# dockerfile: Dockerfile.finder
# init: true
# network_mode: service:gluetun
# volumes:
# - ./finder:/app
# environment:
# FLARESOLVERR_URL: http://flaresolverr:8191
# RELOAD_URL: http://server:8001/api/reload
# depends_on:
# gluetun:
# condition: service_healthy
# flaresolverr:
# condition: service_healthy
# restart: unless-stopped
# healthcheck:
# test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:1234/health')"]
# interval: 30s
# timeout: 5s
# retries: 3
# start_period: 60s
volumes:
pb-data:
@ -184,8 +114,6 @@ volumes:
cargo-target:
frontend-node-modules:
screenshot-cache:
gluetun-cache-v2:
gluetun-auth:
networks:
dev-network:

View file

@ -1,18 +0,0 @@
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml ./
RUN uv pip install --system -r pyproject.toml
RUN playwright install-deps firefox
RUN camoufox fetch \
&& python -c "from camoufox.pkgman import camoufox_path; p = camoufox_path(download_if_missing=False); print('Camoufox verified at', p)"
COPY *.py ./
COPY property-data/arcgis_data.parquet /data/arcgis_data.parquet
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:1234/health')"
CMD ["python3", "main.py"]

View file

@ -1,154 +0,0 @@
import os
from pathlib import Path
ARCGIS_PATH = os.environ.get("ARCGIS_PATH", "/data/arcgis_data.parquet")
DATA_DIR = Path("/app/data")
PAGE_SIZE = 24
DELAY_BETWEEN_PAGES = 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
# Rent sanity bounds (monthly). Rents outside this range are nulled out — they are
# almost always total-stay pricing (e.g. "Golf Open 2026" short lets), annual rents
# mislabelled as monthly, or data errors.
MIN_RENT_MONTHLY = 50 # below £50/month is implausible for any UK property
MAX_RENT_MONTHLY = 25_000 # above £25k/month covers ultra-prime London; higher is suspect
SEED = 42
CHECKPOINT_INTERVAL = int(os.environ.get("CHECKPOINT_INTERVAL", "900")) # seconds
# Schedule: hour of day (UTC) to auto-run scrape. Set to -1 to disable.
SCHEDULE_HOUR = int(os.environ.get("SCHEDULE_HOUR", "3"))
# Whether to run a scrape immediately on startup
RUN_ON_STARTUP = os.environ.get("RUN_ON_STARTUP", "").lower() in ("1", "true", "yes")
# Enable/disable individual sources
SCRAPE_RIGHTMOVE = os.environ.get("SCRAPE_RIGHTMOVE", "true").lower() in (
"1",
"true",
"yes",
)
SCRAPE_HOMECOUK = os.environ.get("SCRAPE_HOMECOUK", "true").lower() in (
"1",
"true",
"yes",
)
SCRAPE_OPENRENT = os.environ.get("SCRAPE_OPENRENT", "true").lower() in (
"1",
"true",
"yes",
)
SCRAPE_ZOOPLA = os.environ.get("SCRAPE_ZOOPLA", "true").lower() in (
"1",
"true",
"yes",
)
# URL to trigger server data reload after scrape (e.g. http://server:8001/api/reload)
RELOAD_URL = os.environ.get("RELOAD_URL", "")
TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
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
HOMECOUK_CONCURRENCY = int(os.environ.get("HOMECOUK_CONCURRENCY", "4"))
# OpenRent
OPENRENT_BASE = "https://www.openrent.co.uk"
# Zoopla
ZOOPLA_BASE = "https://www.zoopla.co.uk"
PROPERTY_TYPE_MAP = {
"Detached": "Detached",
"Semi-Detached": "Semi-Detached",
"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",
"Farm / Barn": "Other",
# Lowercase variants (from home.co.uk / Rightmove APIs)
"house": "Detached",
"bungalow": "Other",
"townhouse": "Terraced",
"land": "Other",
"other": "Other",
"not-specified": "Other",
"retirement-property": "Flats/Maisonettes",
"equestrian-facility": "Other",
"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"},
{"channel": "RENT", "transactionType": "LETTING", "sortType": "6"},
]

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

View file

@ -1,429 +0,0 @@
import json
import logging
import os
import random
import re
import time
from urllib.parse import unquote
import httpx
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 metrics import (
flaresolverr_attempts_total,
homecouk_errors_total,
homecouk_properties_scraped,
homecouk_requests_total,
)
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_CHANNELS = {
"BUY": "for-sale",
"RENT": "to-rent",
}
FLARESOLVERR_URL = os.environ.get("FLARESOLVERR_URL", "http://flaresolverr:8191")
def solve_cloudflare() -> tuple[dict[str, str], str] | None:
"""Use FlareSolverr to solve the Cloudflare challenge.
Returns (cookies_dict, user_agent) or None on failure."""
log.info("Solving Cloudflare challenge via FlareSolverr at %s", FLARESOLVERR_URL)
try:
with httpx.Client(timeout=120) as client:
resp = client.post(
f"{FLARESOLVERR_URL}/v1",
json={
"cmd": "request.get",
"url": f"{HOMECOUK_BASE}/for-sale/e1/",
"maxTimeout": 60000,
},
)
if resp.status_code != 200:
log.error("FlareSolverr returned HTTP %d", resp.status_code)
return None
data = resp.json()
if data.get("status") != "ok":
log.error("FlareSolverr error: %s", data.get("message", "unknown"))
return None
solution = data["solution"]
raw_cookies = solution.get("cookies", [])
user_agent = solution.get("userAgent", "")
# Pass through ALL cookies from FlareSolverr — different Cloudflare
# configurations set different cookies (cf_clearance only appears when
# a challenge is triggered; it's not needed if no challenge was detected)
cookies = {}
for c in raw_cookies:
name = c.get("name", "")
if name:
cookies[name] = c["value"]
if not cookies:
log.error("FlareSolverr solved but returned no cookies at all")
flaresolverr_attempts_total.labels(result="no_cookies").inc()
return None
log.info(
"Cloudflare solved — got %d cookies, UA: %s",
len(cookies),
user_agent[:60],
)
flaresolverr_attempts_total.labels(result="success").inc()
return cookies, user_agent
except (httpx.ConnectError, httpx.ReadTimeout) as e:
log.warning("FlareSolverr not available: %s", e)
flaresolverr_attempts_total.labels(result="unavailable").inc()
return None
except Exception as e:
log.error("FlareSolverr error: %s", e)
flaresolverr_attempts_total.labels(result="error").inc()
return None
def load_cookies() -> tuple[dict[str, str], str] | None:
"""Get home.co.uk cookies + user-agent.
Tries FlareSolverr first, then falls back to environment variables.
Returns (cookies_dict, user_agent) or None if not configured."""
# Try FlareSolverr first
result = solve_cloudflare()
if result:
return result
# Fall back to env vars
cf_clearance = os.environ.get("HOMECOUK_CF_CLEARANCE", "")
session = os.environ.get("HOMECOUK_SESSION", "")
if not cf_clearance or not session:
return None
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",
)
return {"cf_clearance": cf_clearance, "homecouk_session": session}, user_agent
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 cf_clearance cookies (which are bound
to Chrome's JA3 fingerprint from FlareSolverr) remain valid."""
session = Session(impersonate="chrome")
session.headers.update(
{
"User-Agent": user_agent,
"Accept": "application/json, text/plain, */*",
"x-requested-with": "XMLHttpRequest",
}
)
# 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 _status_label(code: int) -> str:
if code >= 500:
return "5xx"
return str(code)
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)
homecouk_requests_total.labels(status=_status_label(resp.status_code)).inc()
if resp.status_code == 200:
try:
return resp.json()
except json.JSONDecodeError:
homecouk_errors_total.labels(type="json_decode").inc()
log.error(
"Non-JSON response from %s (got %s)",
url,
resp.headers.get("content-type", "?"),
)
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:
homecouk_errors_total.labels(type=type(e).__name__).inc()
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
log.warning(
"%s from %s, retry %d/%d in %.1fs",
type(e).__name__,
url,
attempt + 1,
max_retries,
delay,
)
time.sleep(delay)
homecouk_errors_total.labels(type="retry_exhausted").inc()
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,
channel: str,
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": "" if channel == "BUY" else "monthly",
"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,
channel: str,
pc_index: PostcodeSpatialIndex,
) -> list[dict]:
"""Paginate through search results for one outcode+channel.
channel: "BUY" or "RENT".
Returns transformed properties."""
url_segment = HOMECOUK_CHANNELS[channel]
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, channel, pc_index)
if transformed:
properties.append(transformed)
homecouk_properties_scraped.labels(
channel="buy" if channel == "BUY" else "rent",
).inc()
# 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

View file

@ -1,158 +0,0 @@
import logging
import random
import threading
import time
import httpx
from fake_useragent import UserAgent
from constants import MAX_RETRIES, RETRY_BASE_DELAY
from metrics import http_errors_total, http_requests_total, ip_rotations_total
log = logging.getLogger("rightmove")
_ua = UserAgent(
browsers=["Chrome", "Edge"], os=["Windows", "Mac OS X"], min_version=120.0
)
def _endpoint_label(url: str) -> str:
if "typeahead" in url:
return "typeahead"
if "search" in url:
return "search"
return "other"
def _status_label(code: int) -> str:
if code >= 500:
return "5xx"
return str(code)
# Gluetun control API — runs on port 8000 inside the gluetun container.
# Since finder uses network_mode: service:gluetun, localhost IS gluetun.
GLUETUN_API = "http://127.0.0.1:8000"
_ip_rotate_lock = threading.Lock()
def rotate_ip() -> bool:
"""Ask gluetun to reconnect to a different VPN server, getting a new IP.
Returns True if the IP changed successfully."""
with _ip_rotate_lock:
log.info("Rotating VPN IP via gluetun...")
try:
# Get current IP
with httpx.Client(timeout=10) as ctl:
old_ip_resp = ctl.get(f"{GLUETUN_API}/v1/publicip/ip")
old_ip = (
old_ip_resp.json().get("public_ip", "unknown")
if old_ip_resp.status_code == 200
else "unknown"
)
log.info("Current IP: %s", old_ip)
# Trigger server change — PUT with empty JSON body picks a random server
resp = ctl.put(
f"{GLUETUN_API}/v1/vpn/status", json={"status": "stopped"}
)
if resp.status_code != 200:
log.error("Failed to stop VPN: %d %s", resp.status_code, resp.text)
return False
time.sleep(2)
resp = ctl.put(
f"{GLUETUN_API}/v1/vpn/status", json={"status": "running"}
)
if resp.status_code != 200:
log.error("Failed to start VPN: %d %s", resp.status_code, resp.text)
return False
# Wait for reconnection
for _ in range(30):
time.sleep(2)
try:
with httpx.Client(timeout=10) as ctl:
new_ip_resp = ctl.get(f"{GLUETUN_API}/v1/publicip/ip")
if new_ip_resp.status_code == 200:
new_ip = new_ip_resp.json().get("public_ip", "")
if new_ip and new_ip != old_ip:
log.info("IP rotated: %s%s", old_ip, new_ip)
ip_rotations_total.labels(result="success").inc()
return True
except Exception:
pass # VPN still reconnecting
log.warning("IP rotation timed out (may still be same IP)")
ip_rotations_total.labels(result="failure").inc()
return False
except Exception as e:
log.error("IP rotation failed: %s", e)
ip_rotations_total.labels(result="failure").inc()
return False
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.
On 403, triggers IP rotation and retries once."""
endpoint = _endpoint_label(url)
for attempt in range(MAX_RETRIES):
try:
resp = client.get(url, params=params)
http_requests_total.labels(
status=_status_label(resp.status_code), endpoint=endpoint
).inc()
if resp.status_code == 200:
return resp.json()
if resp.status_code == 403 and on_403:
log.warning("HTTP 403 — IP likely blocked, rotating...")
if rotate_ip():
# Retry once with new IP (but don't recurse on 403 again)
return fetch_with_retry(client, url, params, on_403=False)
log.error("IP rotation failed, giving up on %s", 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:
http_errors_total.labels(type=type(e).__name__).inc()
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
log.warning(
"%s from %s, retry %d/%d in %.1fs",
type(e).__name__,
url,
attempt + 1,
MAX_RETRIES,
delay,
)
time.sleep(delay)
http_errors_total.labels(type="retry_exhausted").inc()
log.error("All %d retries exhausted for %s", MAX_RETRIES, url)
return None

View file

@ -1,211 +0,0 @@
import logging
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from flask import Flask, Response, jsonify, send_from_directory
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
from constants import (
DATA_DIR,
RUN_ON_STARTUP,
SCHEDULE_HOUR,
SCRAPE_HOMECOUK,
SCRAPE_OPENRENT,
SCRAPE_RIGHTMOVE,
SCRAPE_ZOOPLA,
)
from homecouk import load_cookies as load_homecouk_cookies
from openrent import load_cookies as load_openrent_cookies
from rightmove import outcode_cache
from scraper import (
_sync_gauges,
build_postcode_coords,
build_postcode_index,
load_outcodes,
run_scrape,
status,
status_lock,
)
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOG_DIR = Path("/app/data")
LOG_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(LOG_DIR / "rightmove.log"),
],
)
log = logging.getLogger("rightmove")
log.setLevel(logging.DEBUG)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
# Suppress noisy /metrics and /health request logs from werkzeug
class _NoiseFilter(logging.Filter):
def filter(self, record):
msg = record.getMessage()
return "GET /metrics" not in msg and "GET /health" not in msg
logging.getLogger("werkzeug").addFilter(_NoiseFilter())
# ---------------------------------------------------------------------------
# Startup: load data
# ---------------------------------------------------------------------------
log.info("Loading arcgis data...")
OUTCODES = load_outcodes()
PC_INDEX = build_postcode_index()
PC_COORDS = build_postcode_coords() if (SCRAPE_OPENRENT or SCRAPE_ZOOPLA) else None
log.info(
"Ready — %d outcodes, postcode index built (rightmove=%s, homecouk=%s, openrent=%s, zoopla=%s)",
len(OUTCODES),
SCRAPE_RIGHTMOVE,
SCRAPE_HOMECOUK,
SCRAPE_OPENRENT,
SCRAPE_ZOOPLA,
)
# ---------------------------------------------------------------------------
# Scheduler
# ---------------------------------------------------------------------------
def _start_scrape() -> bool:
"""Try to start a scrape. Returns True if started, False if already running."""
with status_lock:
if status.state == "running":
return False
status.state = "running"
thread = threading.Thread(
target=run_scrape, args=(OUTCODES, PC_INDEX, PC_COORDS), daemon=True
)
thread.start()
return True
def _seconds_until(hour: int) -> float:
"""Seconds from now until the next occurrence of `hour`:00 UTC."""
now = datetime.now(timezone.utc)
target = now.replace(hour=hour, minute=0, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
return (target - now).total_seconds()
def _scheduler_loop() -> None:
"""Background thread that triggers a daily scrape at SCHEDULE_HOUR UTC."""
log.info("Scheduler active — will run daily at %02d:00 UTC", SCHEDULE_HOUR)
while True:
wait = _seconds_until(SCHEDULE_HOUR)
log.info(
"Next scheduled scrape in %.0f seconds (%.1f hours)", wait, wait / 3600
)
time.sleep(wait)
log.info("Scheduled scrape triggered")
if not _start_scrape():
log.warning("Scheduled scrape skipped — already running")
if RUN_ON_STARTUP:
log.info("RUN_ON_STARTUP=true — starting initial scrape")
_start_scrape()
if SCHEDULE_HOUR >= 0:
scheduler = threading.Thread(target=_scheduler_loop, daemon=True)
scheduler.start()
# ---------------------------------------------------------------------------
# Flask app
# ---------------------------------------------------------------------------
app = Flask(__name__)
@app.route("/health")
def health():
return "ok", 200
@app.route("/run", methods=["POST"])
def trigger_run():
if _start_scrape():
return jsonify({"message": "Scrape started"}), 200
return jsonify({"error": "Scrape already running"}), 409
@app.route("/status")
def get_status():
with status_lock:
elapsed = 0.0
if status.started_at:
end = status.finished_at if status.finished_at else time.time()
elapsed = end - status.started_at
resp = {
"state": status.state,
"channel": status.channel,
"outcode": status.outcode,
"outcodes_done": status.outcodes_done,
"outcodes_total": status.outcodes_total,
"properties_buy": status.properties_buy,
"properties_rent": status.properties_rent,
"properties_by_source": {
"rightmove": status.rm_properties,
"homecouk": status.hk_properties,
"openrent": status.or_properties,
"zoopla": status.zp_properties,
},
"errors": status.errors[-20:], # last 20 errors
"elapsed_seconds": round(elapsed, 1),
}
if SCHEDULE_HOUR >= 0:
resp["next_scrape_in_seconds"] = round(_seconds_until(SCHEDULE_HOUR))
return jsonify(resp)
@app.route("/debug")
def get_debug():
hk_cookies = load_homecouk_cookies() if SCRAPE_HOMECOUK else None
or_cookies = load_openrent_cookies() if SCRAPE_OPENRENT else None
return jsonify(
{
"outcode_cache_size": len(outcode_cache),
"outcode_cache_sample": dict(list(outcode_cache.items())[:20]),
"scrape_rightmove": SCRAPE_RIGHTMOVE,
"scrape_homecouk": SCRAPE_HOMECOUK,
"scrape_openrent": SCRAPE_OPENRENT,
"scrape_zoopla": SCRAPE_ZOOPLA,
"homecouk_cookies_available": hk_cookies is not None,
"openrent_cookies_available": or_cookies is not None,
"zoopla_note": "browser-based (Camoufox), no cookies needed",
}
)
@app.route("/metrics")
def metrics():
with status_lock:
_sync_gauges()
return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST)
@app.route("/data/<filename>")
def serve_data(filename):
if not filename.endswith(".parquet"):
return jsonify({"error": "Only parquet files served"}), 400
return send_from_directory(DATA_DIR, filename)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=1234, debug=False)

View file

@ -1,167 +0,0 @@
from prometheus_client import Counter, Gauge
# ---------------------------------------------------------------------------
# Gauges — current scrape state, updated after each outcode
# ---------------------------------------------------------------------------
scrape_state = Gauge(
"scrape_state",
"Current scrape state as a labeled gauge (1 = active)",
["state"],
)
scrape_outcodes_done = Gauge(
"scrape_outcodes_done",
"Outcodes processed in current channel",
)
scrape_outcodes_total = Gauge(
"scrape_outcodes_total",
"Total outcodes in current channel",
)
scrape_properties_total = Gauge(
"scrape_properties_total",
"Properties found so far",
["channel", "source"],
)
scrape_elapsed_seconds = Gauge(
"scrape_elapsed_seconds",
"Seconds since scrape started",
)
# ---------------------------------------------------------------------------
# Counters — Rightmove (monotonically increasing)
# ---------------------------------------------------------------------------
http_requests_total = Counter(
"http_requests_total",
"HTTP requests made to Rightmove",
["status", "endpoint"],
)
http_errors_total = Counter(
"http_errors_total",
"Rightmove HTTP connection/timeout errors",
["type"],
)
ip_rotations_total = Counter(
"ip_rotations_total",
"VPN IP rotation attempts",
["result"],
)
scrape_errors_total = Counter(
"scrape_errors_total",
"Per-outcode scrape errors",
["source"],
)
# ---------------------------------------------------------------------------
# Counters — home.co.uk
# ---------------------------------------------------------------------------
homecouk_requests_total = Counter(
"homecouk_requests_total",
"HTTP requests made to home.co.uk API",
["status"],
)
homecouk_errors_total = Counter(
"homecouk_errors_total",
"home.co.uk HTTP connection/timeout errors",
["type"],
)
homecouk_properties_scraped = Counter(
"homecouk_properties_scraped",
"Properties scraped from home.co.uk (before dedup)",
["channel"],
)
cross_source_dedup_total = Counter(
"cross_source_dedup_total",
"Properties skipped because same property already found on another source",
["channel"],
)
# ---------------------------------------------------------------------------
# Counters — OpenRent
# ---------------------------------------------------------------------------
openrent_requests_total = Counter(
"openrent_requests_total",
"HTTP requests made to OpenRent",
["status"],
)
openrent_errors_total = Counter(
"openrent_errors_total",
"OpenRent HTTP connection/timeout errors",
["type"],
)
openrent_properties_scraped = Counter(
"openrent_properties_scraped",
"Properties scraped from OpenRent (before dedup)",
["channel"],
)
# ---------------------------------------------------------------------------
# Counters — Zoopla
# ---------------------------------------------------------------------------
zoopla_pages_scraped = Counter(
"zoopla_pages_scraped",
"Search result pages scraped from Zoopla",
["channel"],
)
zoopla_errors_total = Counter(
"zoopla_errors_total",
"Zoopla scraping errors",
["type"],
)
zoopla_properties_scraped = Counter(
"zoopla_properties_scraped",
"Properties scraped from Zoopla (before dedup)",
["channel"],
)
# ---------------------------------------------------------------------------
# Counters — FlareSolverr / cookie management
# ---------------------------------------------------------------------------
flaresolverr_attempts_total = Counter(
"flaresolverr_attempts_total",
"FlareSolverr Cloudflare challenge-solving attempts",
["result"],
)
cookie_refreshes_total = Counter(
"cookie_refreshes_total",
"home.co.uk cookie refresh attempts (triggered by 403)",
["result"],
)
# ---------------------------------------------------------------------------
# Gauges — home.co.uk state
# ---------------------------------------------------------------------------
homecouk_enabled = Gauge(
"homecouk_enabled",
"Whether home.co.uk scraping is currently active (1=yes, 0=no)",
)
openrent_enabled = Gauge(
"openrent_enabled",
"Whether OpenRent scraping is currently active (1=yes, 0=no)",
)
zoopla_enabled = Gauge(
"zoopla_enabled",
"Whether Zoopla scraping is currently active (1=yes, 0=no)",
)

View file

@ -1,6 +0,0 @@
Hit the following url with the outcode as the location-id and the page. So for E13, page 2 it's:
https://www.onthemarket.com/async/search/properties-v2/?search-type=for-sale&location-id=e13&page=2&view=map-list
and the response is in [[response.json]]

File diff suppressed because it is too large Load diff

View file

@ -1,869 +0,0 @@
"""OpenRent (openrent.co.uk) scraper — rental properties only.
OpenRent is behind AWS WAF, so we use Playwright (headless Chromium) to solve
the challenge and get valid cookies. Then we use curl_cffi with Chrome TLS
impersonation to make requests with those cookies.
OpenRent is a rental-only platform, so this scraper only handles RENT channel.
HTML structure (as of 2026-03):
Search results page renders property cards as <a class="pli search-property-card">.
Each card contains:
- Monthly price in <div class="pim"> with <span class="text-primary">£X,XXX</span>
- Weekly price in <div class="piw"> (hidden by Alpine.js)
- Title in <div class="fw-medium text-primary fs-3">N Bed Type, Location, OUTCODE</div>
- Features in <ul> with <li> items like "1 Bed", "1 Bath", "Furnished"
- Listing ID in data-listing-id on the .or-swiper div
- Description snippet in <div class="line-clamp-2">
Detail page has:
- <h1> with property title including outcode
- <div id="map" data-lat="..." data-lng="..."> for coordinates
- Tables with deposit, rent, furnishing, tenant preferences
"""
import logging
import os
import re
import time
from bs4 import BeautifulSoup
from curl_cffi.requests import Session
from curl_cffi.requests.errors import RequestsError
from playwright.sync_api import sync_playwright
from constants import (
DELAY_BETWEEN_PAGES,
MAX_BEDROOMS,
OPENRENT_BASE,
PROPERTY_TYPE_MAP,
RETRY_BASE_DELAY,
)
from metrics import (
flaresolverr_attempts_total,
openrent_errors_total,
openrent_properties_scraped,
openrent_requests_total,
)
from spatial import PostcodeSpatialIndex
from transform import normalize_postcode, normalize_sub_type, validate_floor_area
log = logging.getLogger("openrent")
class WafChallengeError(Exception):
"""Raised when OpenRent returns a WAF challenge, indicating cookies need refresh."""
# ---------------------------------------------------------------------------
# Cookie / session management via Playwright
# ---------------------------------------------------------------------------
def solve_waf() -> tuple[dict[str, str], str] | None:
"""Use Playwright (headless Chromium) to solve the AWS WAF challenge.
Returns (cookies_dict, user_agent) or None on failure."""
log.info("Solving AWS WAF challenge via Playwright")
try:
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-blink-features=AutomationControlled"],
)
context = browser.new_context()
page = context.new_page()
url = f"{OPENRENT_BASE}/properties-to-rent/?term=london&isLive=true"
log.info("Navigating to %s", url)
page.goto(url, wait_until="domcontentloaded", timeout=60000)
content = page.content()
if "AwsWafIntegration" in content:
log.info("Got WAF challenge page, waiting for resolution...")
page.wait_for_selector(
"a.pli, .pli, .search-property-card",
timeout=30000,
)
raw_cookies = context.cookies()
user_agent = page.evaluate("navigator.userAgent")
browser.close()
cookies = {c["name"]: c["value"] for c in raw_cookies}
if "aws-waf-token" not in cookies:
log.error("Playwright solved page but no aws-waf-token cookie found")
flaresolverr_attempts_total.labels(result="no_cookies").inc()
return None
log.info(
"AWS WAF solved — got %d cookies, UA: %s",
len(cookies),
user_agent[:60],
)
flaresolverr_attempts_total.labels(result="success").inc()
return cookies, user_agent
except Exception as e:
log.error("Playwright WAF solve failed: %s", e)
flaresolverr_attempts_total.labels(result="error").inc()
return None
def load_cookies() -> tuple[dict[str, str], str] | None:
"""Get OpenRent cookies + user-agent.
Tries Playwright first, then falls back to environment variables."""
result = solve_waf()
if result:
return result
# Fall back to env vars
waf_token = os.environ.get("OPENRENT_WAF_TOKEN", "")
if not waf_token:
return None
user_agent = os.environ.get(
"OPENRENT_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",
)
return {"aws-waf-token": waf_token}, user_agent
def make_client(cookies: dict[str, str], user_agent: str) -> Session:
"""Create a curl_cffi Session configured for OpenRent.
Uses Chrome TLS impersonation so AWS WAF cookies remain valid."""
session = Session(impersonate="chrome")
session.headers.update(
{
"User-Agent": user_agent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-GB,en;q=0.9",
}
)
for name, value in cookies.items():
session.cookies.set(name, value, domain="openrent.co.uk")
return session
# ---------------------------------------------------------------------------
# HTTP fetch with retry
# ---------------------------------------------------------------------------
def _status_label(code: int) -> str:
if code >= 500:
return "5xx"
return str(code)
def fetch_page(
client: Session,
url: str,
max_retries: int = 3,
) -> str | None:
"""GET HTML with retries on 429/5xx. Returns None on permanent failure.
WAF challenge (202 or 403 with challenge JS) raises WafChallengeError."""
for attempt in range(max_retries):
try:
resp = client.get(url, timeout=30)
openrent_requests_total.labels(status=_status_label(resp.status_code)).inc()
if resp.status_code == 200:
html = resp.text
# Detect WAF challenge page masquerading as 200
if "AwsWafIntegration" in html and "challenge.js" in html:
raise WafChallengeError(
"Got AWS WAF challenge page — cookies expired"
)
return html
if resp.status_code in (202, 403):
raise WafChallengeError(
f"HTTP {resp.status_code} — cookies likely expired"
)
if resp.status_code in (429, 500, 502, 503, 504):
delay = RETRY_BASE_DELAY * (2**attempt)
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 WafChallengeError:
raise
except RequestsError as e:
openrent_errors_total.labels(type=type(e).__name__).inc()
delay = RETRY_BASE_DELAY * (2**attempt)
log.warning(
"%s from %s, retry %d/%d in %.1fs",
type(e).__name__,
url,
attempt + 1,
max_retries,
delay,
)
time.sleep(delay)
openrent_errors_total.labels(type="retry_exhausted").inc()
log.error("All %d retries exhausted for %s", max_retries, url)
return None
# ---------------------------------------------------------------------------
# HTML parsing
# ---------------------------------------------------------------------------
def _extract_price_from_element(el) -> tuple[int, str] | None:
"""Extract price integer from a price element's text like '£2,100'."""
if not el:
return None
text = el.get_text(strip=True)
match = re.search(r"£([\d,]+)", text)
if not match:
return None
return int(match.group(1).replace(",", ""))
def _extract_price(text: str) -> tuple[int, str] | None:
"""Extract price and frequency from text like '£1,500 pcm' or '£350 pw'.
Returns (price_int, frequency) or None.
OpenRent card text shows both monthly and weekly prices (e.g.
'£2,800 per month £646 per week'), so check monthly *before* weekly
to match the first (monthly) price that the regex captures."""
match = re.search(r"£([\d,]+)", text)
if not match:
return None
price = int(match.group(1).replace(",", ""))
lower = text.lower()
if "pcm" in lower or "per month" in lower or "/m" in lower:
return price, "monthly"
if "pw" in lower or "per week" in lower or "/w" in lower:
return price, "weekly"
if "pa" in lower or "per annum" in lower or "/y" in lower:
return price, "yearly"
# OpenRent defaults to pcm (per calendar month)
return price, "monthly"
def _extract_bedrooms_from_title(title: str) -> int | None:
"""Extract bedroom count from title like '2 Bed Flat, Pimlico'."""
match = re.search(r"(\d+)\s*bed", title, re.IGNORECASE)
if match:
return int(match.group(1))
if re.search(r"\bstudio\b", title, re.IGNORECASE):
return 0
return None
def _extract_beds_baths_from_features(
feature_items: list,
) -> tuple[int | None, int | None]:
"""Extract bedrooms and bathrooms from feature list items.
OpenRent search cards have <ul> with items like:
<li>1 Bed</li> <li>1 Bath</li> <li>Furnished</li>
"""
bedrooms = None
bathrooms = None
for li in feature_items:
text = li.get_text(strip=True).lower()
bed_match = re.search(r"(\d+)\s*bed", text)
if bed_match:
bedrooms = int(bed_match.group(1))
bath_match = re.search(r"(\d+)\s*bath", text)
if bath_match:
bathrooms = int(bath_match.group(1))
return bedrooms, bathrooms
def _extract_postcode(text: str) -> str | None:
"""Extract full UK postcode from text like '2 Bed Flat, Pimlico, SW1V 2AA'.
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): "IP265AT" → "IP26 5AT"
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 UK outcode from text like '1 Bed Flat, Bank Chambers, SW1Y'.
Looks for an outcode pattern (e.g., SW1Y, E1, EC2A) at the end of the text
or after the last comma."""
# Try after last comma first (most reliable position in OpenRent titles)
parts = text.split(",")
if len(parts) > 1:
last_part = parts[-1].strip()
match = re.match(r"^([A-Z]{1,2}\d[A-Z0-9]?)$", last_part, re.IGNORECASE)
if match:
return match.group(1).upper()
# Fall back to searching anywhere in text
match = re.search(r"\b([A-Z]{1,2}\d[A-Z0-9]?)\b", text, re.IGNORECASE)
if match:
candidate = match.group(1).upper()
# Avoid matching things like "1 Bed" → "1B"
if len(candidate) >= 2 and not candidate[0].isdigit():
return candidate
return None
def _infer_property_type(title: str) -> str:
"""Infer property type from title text.
Order matters: "Room in a Shared Flat" should be "Room" not "Flat",
so check "room" before "flat"."""
lower = title.lower()
if "room in" in lower or "room " in lower:
return "Room"
if "studio" in lower:
return "Studio"
if "flat" in lower or "apartment" in lower:
return "Flat"
if "maisonette" in lower:
return "Maisonette"
if "house" in lower:
return "House"
if "bungalow" in lower:
return "Bungalow"
return ""
def parse_search_results(html: str) -> list[dict]:
"""Parse property data from OpenRent search results HTML.
Returns list of raw property dicts extracted from property cards.
Current OpenRent card structure (2026-03):
<a class="pli search-property-card" href="/property-to-rent/.../ID">
<div class="or-swiper" data-listing-id="ID">
<div class="pim"><span class="text-primary">£2,100</span> per month</div>
<div class="piw"><span class="text-primary">£485</span> per week</div>
<div class="fw-medium text-primary fs-3">1 Bed Flat, Location, SW1Y</div>
<ul>...<li>1 Bed</li><li>1 Bath</li><li>Furnished</li>...</ul>
"""
soup = BeautifulSoup(html, "lxml")
properties = []
# Property cards: <a class="pli search-property-card">
cards = soup.select("a.pli")
if not cards:
cards = soup.find_all("a", href=re.compile(r"/property-to-rent/"))
if not cards:
log.warning(
"No property cards found in search HTML (%d bytes). "
"CSS selectors may need updating.",
len(html),
)
return []
for card in cards:
prop: dict = {}
# Extract property URL and ID from href
href = card.get("href", "")
if not href:
continue
prop["url"] = href if href.startswith("http") else OPENRENT_BASE + href
id_match = re.search(r"/(\d+)(?:\?|$|#)", href)
if id_match:
prop["id"] = id_match.group(1)
else:
# Try data-listing-id on the swiper element
swiper = card.select_one("[data-listing-id]")
if swiper:
prop["id"] = swiper["data-listing-id"]
else:
continue # can't use a property without an ID
# --- Price ---
# Prefer structured price elements over free-text parsing.
# Monthly price is in <div class="pim"><span class="text-primary">£X</span>
pim = card.select_one(".pim .text-primary, .pim span")
piw = card.select_one(".piw .text-primary, .piw span")
monthly_price = _extract_price_from_element(pim)
weekly_price = _extract_price_from_element(piw)
if monthly_price:
prop["price"] = monthly_price
prop["frequency"] = "monthly"
elif weekly_price:
prop["price"] = weekly_price
prop["frequency"] = "weekly"
else:
# Fall back to parsing card text
card_text = card.get_text(" ", strip=True)
price_result = _extract_price(card_text)
if price_result:
prop["price"], prop["frequency"] = price_result
# --- Title / Address ---
# The property title is in a div with classes "fw-medium text-primary fs-3"
# e.g., "1 Bed Flat, Bank Chambers, SW1Y"
title_el = card.select_one("div.fw-medium.fs-3")
if not title_el:
# Fallback: try image alt text which also has the title
img = card.select_one("img.propertyPic")
if img and img.get("alt"):
prop["title"] = img["alt"]
else:
# Last resort: extract from card text, excluding price/nav noise
prop["title"] = ""
else:
prop["title"] = title_el.get_text(strip=True)
# --- Bedrooms / Bathrooms from feature list ---
feature_list = card.select("ul li")
beds_from_features, baths_from_features = _extract_beds_baths_from_features(
feature_list,
)
# Bedrooms: prefer feature list, fall back to title parsing
if beds_from_features is not None:
prop["bedrooms"] = beds_from_features
else:
beds = _extract_bedrooms_from_title(prop.get("title", ""))
if beds is not None:
prop["bedrooms"] = beds
if baths_from_features is not None:
prop["bathrooms"] = baths_from_features
# --- Property type from title ---
title = prop.get("title", "")
prop["property_type"] = _infer_property_type(title)
# --- Postcode / outcode from title ---
postcode = _extract_postcode(title)
if postcode:
prop["postcode"] = postcode
else:
outcode = _extract_outcode(title)
if outcode:
prop["outcode"] = outcode
# --- Description snippet ---
desc_el = card.select_one(".line-clamp-2")
if desc_el:
prop["description"] = desc_el.get_text(strip=True)
# --- Coordinates from data attributes (may not be present on cards) ---
for el in [card] + card.select("[data-lat], [data-latitude]"):
lat = el.get("data-lat") or el.get("data-latitude")
lng = el.get("data-lng") or el.get("data-longitude") or el.get("data-lon")
if lat and lng:
try:
prop["lat"] = float(lat)
prop["lng"] = float(lng)
except ValueError:
pass
break
properties.append(prop)
log.debug("Parsed %d property cards from search HTML", len(properties))
return properties
def parse_property_detail(html: str) -> dict:
"""Parse a single property detail page for additional data.
Current detail page structure (2026-03):
- <h1> has the full title (e.g., "Room in a Shared House, Lime Tree Court, AL2")
- <div id="map" data-lat="..." data-lng="..."> has coordinates
- Tables have "Rent PCM", "Deposit", "Bills Included", etc. (NOT bedrooms)
- Description in elements with class containing "description"
"""
soup = BeautifulSoup(html, "lxml")
details: dict = {}
# --- Title from h1 ---
h1 = soup.select_one("h1")
if h1:
title_text = h1.get_text(strip=True)
# Validate it's not a nav/modal element (e.g. "Log in")
if len(title_text) > 10 and "log in" not in title_text.lower():
details["title"] = title_text
postcode = _extract_postcode(title_text)
if postcode:
details["postcode"] = postcode
# --- Coordinates from map element ---
# The map div has id="map" with data-lat and data-lng
map_el = soup.select_one("#map[data-lat]")
if not map_el:
# Fallback: any element with data-lat (but prefer #map)
map_el = soup.select_one("[data-lat]")
if map_el:
lat = map_el.get("data-lat")
lng = map_el.get("data-lng") or map_el.get("data-lon")
if lat and lng:
try:
details["lat"] = float(lat)
details["lng"] = float(lng)
except ValueError:
pass
# --- Parse tables for rent and property details ---
for table in soup.select("table"):
for row in table.select("tr"):
cells = row.select("td")
if len(cells) < 2:
continue
label = cells[0].get_text(strip=True).lower()
value = cells[1].get_text(strip=True)
if "rent" in label and "pcm" in label:
match = re.search(r"£([\d,]+)", value)
if match:
details["price"] = int(match.group(1).replace(",", ""))
elif "bedroom" in label:
match = re.search(r"(\d+)", value)
if match:
details["bedrooms"] = int(match.group(1))
elif "bathroom" in label:
match = re.search(r"(\d+)", value)
if match:
details["bathrooms"] = int(match.group(1))
elif "type" in label and "property" in label:
details["property_type"] = value
elif "available" in label or "move" in label:
details["available_date"] = value
elif "furnish" in label:
details["furnished"] = value
# --- Coordinates from inline JavaScript (last resort) ---
if "lat" not in details:
for script in soup.select("script"):
text = script.string or ""
lat_match = re.search(r'"latitude"\s*:\s*([\d.-]+)', text)
lng_match = re.search(r'"longitude"\s*:\s*([\d.-]+)', text)
if lat_match and lng_match:
try:
details["lat"] = float(lat_match.group(1))
details["lng"] = float(lng_match.group(1))
except ValueError:
pass
break
# --- Description for floor area ---
desc_el = soup.select_one(".description, [class*='description'], #description")
if desc_el:
details["description"] = desc_el.get_text(strip=True)
return details
# ---------------------------------------------------------------------------
# Property type mapping & floor area
# ---------------------------------------------------------------------------
def map_property_type(raw_type: str | None) -> str:
"""Map OpenRent property type to canonical type."""
if not raw_type:
return "Other"
canonical = PROPERTY_TYPE_MAP.get(raw_type)
if canonical:
return canonical
lower = raw_type.lower()
if "room" in lower or "shared" in lower:
return "Other"
if (
"flat" in lower
or "apartment" in lower
or "maisonette" in lower
or "studio" in lower
):
return "Flats/Maisonettes"
if "detached" in lower and "semi" not in lower:
return "Detached"
if "semi" in lower:
return "Semi-Detached"
if "terrace" in lower or "mews" in lower:
return "Terraced"
if "house" in lower:
return "Detached"
log.debug("Unknown property type: %r — mapping to Other", raw_type)
return "Other"
def parse_floor_area(description: str | None) -> float | None:
"""Try to extract floor area from description text."""
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
# ---------------------------------------------------------------------------
# Transform & search
# ---------------------------------------------------------------------------
def _resolve_outcode_postcodes(
outcode: str,
pc_coords: dict[str, tuple[float, float]],
) -> list[str]:
"""Get all postcodes for an outcode from the postcode coordinates lookup."""
# ONSPD 7-char format: 4-char outcodes have no space before incode
# (e.g., "BH191AB"), while shorter outcodes do (e.g., "E14 5AB").
prefix = outcode + " "
results = [pcd for pcd in pc_coords if pcd.startswith(prefix)]
if not results and len(outcode) >= 4:
results = [pcd for pcd in pc_coords if pcd.startswith(outcode) and len(pcd) > len(outcode)]
return results
def _parse_or_date(date_str: str) -> str:
"""Parse OpenRent date strings to ISO format (YYYY-MM-DD).
Handles 'Today', 'Tomorrow', and 'DD Month, YYYY' formats."""
if not date_str:
return ""
stripped = date_str.strip()
lower = stripped.lower()
if lower == "today":
from datetime import datetime
return datetime.now().strftime("%Y-%m-%d")
if lower == "tomorrow":
from datetime import datetime, timedelta
return (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d")
# Try "DD Month, YYYY" format (e.g., "01 April, 2026")
from datetime import datetime
for fmt in ("%d %B, %Y", "%d %B %Y"):
try:
return datetime.strptime(stripped, fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return date_str # Return as-is if unparseable
def transform_property(
search_data: dict,
detail_data: dict | None,
pc_index: PostcodeSpatialIndex,
pc_coords: dict[str, tuple[float, float]],
) -> dict | None:
"""Transform OpenRent property data into our output schema.
Merges data from the search results page and (optionally) the detail page.
Uses pc_coords (postcode -> lat/lng) as a fallback when coordinates are
missing but a postcode is available.
"""
detail = detail_data or {}
# Merge: detail page data takes precedence
lat = detail.get("lat") or search_data.get("lat")
lng = detail.get("lng") or search_data.get("lng")
price = detail.get("price") or search_data.get("price")
if not price or int(price) <= 0:
return None
frequency = search_data.get("frequency", "monthly")
# Get postcode: detail page > search card
postcode = detail.get("postcode") or search_data.get("postcode")
if lat is not None and lng is not 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
if not postcode:
if pc_index:
postcode = pc_index.nearest(lat, lng)
elif search_data.get("outcode"):
# No spatial index — try outcode lookup as fallback
outcode_pcs = _resolve_outcode_postcodes(
search_data["outcode"],
pc_coords,
)
if outcode_pcs:
postcode = outcode_pcs[0]
elif postcode:
# Have postcode but no coordinates — look up centroid from arcgis data
coords = pc_coords.get(postcode)
if coords:
lat, lng = coords
else:
log.debug("Postcode %s not in arcgis data — skipping", postcode)
return None
elif search_data.get("outcode"):
# Have only outcode — find postcodes in that outcode and use centroid
outcode = search_data["outcode"]
outcode_postcodes = _resolve_outcode_postcodes(outcode, pc_coords)
if outcode_postcodes:
# Use the first postcode as a rough approximation
postcode = outcode_postcodes[0]
lat, lng = pc_coords[postcode]
else:
log.debug("No postcodes found for outcode %s — skipping", outcode)
return None
else:
return None
if not postcode:
log.debug("No postcode for property — skipping")
return None
raw_beds = detail.get("bedrooms") or search_data.get("bedrooms", 0) or 0
raw_baths = detail.get("bathrooms") or search_data.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(
"OpenRent %s: implausible beds=%d baths=%d (capped to 0)",
search_data.get("id", "?"), raw_beds, raw_baths,
)
# Title: prefer detail page (has h1 with full title)
title = detail.get("title") or search_data.get("title", "")
# Address: take the middle part of the title (skip the "N Bed Type" prefix
# and the outcode suffix). E.g., "1 Bed Flat, Bank Chambers, SW1Y" -> "Bank Chambers"
address = ""
if title:
parts = [p.strip() for p in title.split(",")]
if len(parts) >= 3:
# Skip first (type) and last (outcode), join the middle
address = ", ".join(parts[1:-1])
elif len(parts) == 2:
# Could be "Location, OUTCODE" or "Type, Location"
# If last part looks like an outcode, use the first part
if re.match(r"^[A-Z]{1,2}\d", parts[-1].strip()):
address = parts[0]
else:
address = parts[1]
else:
address = title
# Property type: prefer detail, then search card, then infer from title
property_type = detail.get("property_type") or search_data.get("property_type", "")
if not property_type and title:
property_type = _infer_property_type(title)
prop_id = search_data.get("id", "")
listing_url = search_data.get(
"url",
f"{OPENRENT_BASE}/{prop_id}" if prop_id else "",
)
description = detail.get("description") or search_data.get("description", "")
return {
"id": f"or_{prop_id}",
"Bedrooms": bedrooms,
"Bathrooms": bathrooms,
"Number of bedrooms & living rooms": bedrooms,
"lon": lng,
"lat": lat,
"Postcode": normalize_postcode(postcode),
"Address per Property Register": address,
# OpenRent is a rental-only platform — tenure (Freehold/Leasehold) is a
# property ownership concept that doesn't apply to rental listings. The
# landlord's tenure is not shown on OpenRent listing pages.
"Leasehold/Freehold": None,
"Property type": map_property_type(property_type),
"Property sub-type": normalize_sub_type(property_type),
"price": int(price),
"price_frequency": frequency,
"Price qualifier": "",
"Total floor area (sqm)": parse_floor_area(description),
"Listing URL": listing_url,
"Listing features": [],
"first_visible_date": _parse_or_date(detail.get("available_date", "")),
}
def search_outcode(
client: Session,
outcode: str,
pc_index: PostcodeSpatialIndex,
pc_coords: dict[str, tuple[float, float]],
fetch_details: bool = True,
) -> list[dict]:
"""Search OpenRent for rental properties in one outcode.
1. Fetches the search results page for the outcode
2. Parses property cards from the HTML (title, price, beds, baths)
3. Fetches each property's detail page for coordinates
4. Transforms to common output schema
The search card provides most data (price, bedrooms, bathrooms, title,
property type). Detail pages are needed primarily for precise coordinates
and full postcodes. When detail pages fail, we fall back to outcode-level
coordinates from the postcode lookup.
"""
search_url = f"{OPENRENT_BASE}/properties-to-rent/?term={outcode}&isLive=true"
html = fetch_page(client, search_url)
if not html:
return []
search_results = parse_search_results(html)
if not search_results:
return []
properties = []
for search_data in search_results:
detail_data = None
# Skip detail page if we already have coordinates or a resolvable postcode
has_coords = (
search_data.get("lat") is not None
and search_data.get("lng") is not None
)
has_resolvable_pc = (
search_data.get("postcode")
and pc_coords
and search_data["postcode"] in pc_coords
)
needs_detail = (
fetch_details
and search_data.get("url")
and not has_coords
and not has_resolvable_pc
)
if needs_detail:
detail_html = fetch_page(client, search_data["url"])
if detail_html:
detail_data = parse_property_detail(detail_html)
# Shorter delay for detail pages (within same outcode)
time.sleep(0.15)
transformed = transform_property(
search_data,
detail_data,
pc_index,
pc_coords,
)
if transformed:
properties.append(transformed)
openrent_properties_scraped.labels(channel="rent").inc()
return properties

View file

@ -1,17 +0,0 @@
[project]
name = "finder"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"flask",
"httpx",
"curl_cffi",
"polars",
"fake-useragent>=2.2.0",
"prometheus-client",
"beautifulsoup4",
"lxml",
"playwright>=1.58.0",
"playwright-stealth>=2.0.2",
"camoufox>=0.4.11",
]

View file

@ -1,156 +0,0 @@
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,
) -> 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)
# 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,
) -> 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
)
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},
)
new = 0
for p in pt_props:
if p["id"] not in all_by_id:
all_by_id[p["id"]] = p
new += 1
if new:
log.debug("%s/%s type=%s: +%d new properties", outcode, ch, pt, new)
log.info(
"%s/%s: type split recovered %d%d properties",
outcode, ch, len(properties), len(all_by_id),
)
return list(all_by_id.values())

File diff suppressed because it is too large Load diff

View file

@ -1,52 +0,0 @@
The API works as follows, you must search for outcodes, such as E11, then hit https://los.rightmove.co.uk/typeahead?query=E11&limit=10&exclude=STREET which will return something like:
{
"matches": [
{
"id": "746",
"type": "OUTCODE",
"displayName": "E11",
"highlighting": "<span class='highlightLetter'>E11</span>",
"highlights": [
{
"text": "E11",
"highlighted": true
}
]
},
{
"id": "749",
"type": "OUTCODE",
"displayName": "E14",
"highlighting": "displayName",
"highlights": []
},
{
"id": "752",
"type": "OUTCODE",
"displayName": "E17",
"highlighting": "displayName",
"highlights": []
},
...
]
}
We need to find the id of the object which has "type": "OUTCODE", and displayName matching the outcode we searched for, in this case E11, which is 746. Then we can hit the search endpoint with that id, and it will return the properties for that outcode:
https://www.rightmove.co.uk/api/property-search/listing/search?useLocationIdentifier=true&locationIdentifier=OUTCODE%5E746&buy=For+sale&_includeSSTC=on&index=0&sortType=2&channel=BUY&transactionType=BUY&displayLocationIdentifier=E12.html
You can see the example response to this at [[buy.json]]
You must set locationIdentifier=OUTCODE%5E{id} where id is 746 in this case, so it's 746 locationIdentifier=OUTCODE%5E746. Paging works by increasing index by the number of results per page, which is 24. So the next page would be index=24, then index=48, etc.
The rental endpoint works similarly:
https://www.rightmove.co.uk/api/property-search/listing/search?locationIdentifier=OUTCODE%5E745&index=0&sortType=6&channel=RENT&transactionType=LETTING&displayLocationIdentifier=E16.html
https://www.rightmove.co.uk/api/property-search/listing/search?locationIdentifier=OUTCODE%5E752&index=48&sortType=6&channel=RENT&transactionType=LETTING&displayLocationIdentifier=E17.html
See a response example for the rental endpoint at [[rent.json]]

File diff suppressed because it is too large Load diff

View file

@ -1,993 +0,0 @@
import json
import logging
import random
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
import polars as pl
import httpx
from constants import (
ARCGIS_PATH,
CHANNELS,
CHECKPOINT_INTERVAL,
DATA_DIR,
DELAY_BETWEEN_OUTCODES,
HOMECOUK_CONCURRENCY,
RELOAD_URL,
SCRAPE_HOMECOUK,
SCRAPE_OPENRENT,
SCRAPE_RIGHTMOVE,
SCRAPE_ZOOPLA,
SEED,
)
from homecouk import CookiesExpiredError
from homecouk import load_cookies as load_homecouk_cookies
from homecouk import make_client as make_homecouk_client
from homecouk import search_outcode as homecouk_search_outcode
from http_client import make_client
from metrics import (
cookie_refreshes_total,
cross_source_dedup_total,
homecouk_enabled,
openrent_enabled,
scrape_elapsed_seconds,
scrape_errors_total,
scrape_outcodes_done,
scrape_outcodes_total,
scrape_properties_total,
scrape_state,
zoopla_enabled,
)
from openrent import WafChallengeError
from openrent import load_cookies as load_openrent_cookies
from openrent import make_client as make_openrent_client
from openrent import search_outcode as openrent_search_outcode
from rightmove import resolve_outcode_id, search_outcode
from zoopla import TurnstileError
from zoopla import launch_browser as launch_zoopla_browser
from zoopla import search_outcode as zoopla_search_outcode
from spatial import PostcodeSpatialIndex
from storage import write_parquet
log = logging.getLogger("rightmove")
@dataclass
class ScrapeStatus:
state: str = "idle" # idle | running | done | error
channel: str = ""
outcode: str = ""
outcodes_done: int = 0
outcodes_total: int = 0
properties_buy: int = 0
properties_rent: int = 0
# Per-source counts (combined across channels)
rm_properties: int = 0
hk_properties: int = 0
or_properties: int = 0
zp_properties: int = 0
errors: list[str] = field(default_factory=list)
started_at: float = 0.0
finished_at: float = 0.0
status = ScrapeStatus()
status_lock = threading.Lock()
def _sync_gauges() -> None:
"""Push current ScrapeStatus values into Prometheus gauges. Must hold status_lock."""
for state in ("idle", "running", "done", "error"):
scrape_state.labels(state=state).set(1 if status.state == state else 0)
scrape_outcodes_done.set(status.outcodes_done)
scrape_outcodes_total.set(status.outcodes_total)
scrape_properties_total.labels(channel="buy", source="total").set(
status.properties_buy
)
scrape_properties_total.labels(channel="rent", source="total").set(
status.properties_rent
)
# Per-source totals (across both channels)
for ch in ("buy", "rent"):
scrape_properties_total.labels(channel=ch, source="rightmove").set(
status.rm_properties
)
scrape_properties_total.labels(channel=ch, source="homecouk").set(
status.hk_properties
)
scrape_properties_total.labels(channel=ch, source="openrent").set(
status.or_properties
)
scrape_properties_total.labels(channel=ch, source="zoopla").set(
status.zp_properties
)
if status.started_at:
end = status.finished_at if status.finished_at else time.time()
scrape_elapsed_seconds.set(end - status.started_at)
else:
scrape_elapsed_seconds.set(0)
def load_outcodes() -> list[str]:
"""Load England-only outcodes from arcgis parquet."""
log.info("Loading outcodes from %s", ARCGIS_PATH)
df = pl.read_parquet(ARCGIS_PATH, columns=["pcd", "ctry", "lat", "long"])
england = df.filter(pl.col("ctry") == "E92000001")
log.info("England postcodes: %d", len(england))
outcodes = (
england.select(
pl.col("pcd").str.extract(r"^([A-Z]{1,2}\d[A-Z0-9]?)", 1).alias("outcode")
)
.drop_nulls()
.get_column("outcode")
.unique()
.sort()
.to_list()
)
log.info("Unique England outcodes: %d", len(outcodes))
return outcodes
def build_postcode_index() -> PostcodeSpatialIndex:
"""Build spatial index from arcgis England postcodes."""
log.info("Building postcode spatial index from %s", ARCGIS_PATH)
df = pl.read_parquet(ARCGIS_PATH, columns=["pcd", "ctry", "lat", "long"])
england = df.filter(pl.col("ctry") == "E92000001").drop_nulls(
subset=["lat", "long"]
)
return PostcodeSpatialIndex(
england.get_column("lat").to_list(),
england.get_column("long").to_list(),
england.get_column("pcd").to_list(),
)
def build_postcode_coords() -> dict[str, tuple[float, float]]:
"""Build postcode → (lat, lng) lookup from arcgis England postcodes.
Used by OpenRent scraper to resolve coordinates from postcodes."""
log.info("Building postcode coords lookup from %s", ARCGIS_PATH)
df = pl.read_parquet(ARCGIS_PATH, columns=["pcd", "ctry", "lat", "long"])
england = df.filter(pl.col("ctry") == "E92000001").drop_nulls(
subset=["lat", "long"]
)
coords: dict[str, tuple[float, float]] = {}
for pcd, lat, lng in zip(
england.get_column("pcd").to_list(),
england.get_column("lat").to_list(),
england.get_column("long").to_list(),
):
coords[pcd] = (lat, lng)
log.info("Postcode coords lookup: %d postcodes", len(coords))
return coords
def _fmt_elapsed(seconds: float) -> str:
"""Format seconds as e.g. '2h13m' or '5m32s'."""
h, rem = divmod(int(seconds), 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}h{m:02d}m"
return f"{m}m{s:02d}s"
def _dedup_key(p: dict) -> tuple:
"""Composite key for cross-source deduplication: (postcode, bedrooms, price).
Two listings on different portals for the same physical property will share
these attributes even though their IDs differ."""
return (p.get("Postcode", ""), p.get("Bedrooms", 0), p.get("price", 0))
class _Progress:
"""Thread-safe progress tracker for parallel source workers."""
def __init__(self):
self._counts: dict[str, int] = {}
self._lock = threading.Lock()
def update(self, source: str, done: int) -> None:
with self._lock:
self._counts[source] = done
def snapshot(self) -> dict[str, int]:
with self._lock:
return dict(self._counts)
def _merge_channel(
rm_props: list[dict],
hk_props: list[dict],
or_props: list[dict],
zp_props: list[dict],
) -> tuple[dict[str, dict], dict[str, int], int]:
"""Merge properties from all sources for one channel with cross-source dedup.
Rightmove has priority; other sources are checked for duplicates.
Returns (all_properties_by_id, per_source_counts, total_dedup_count).
"""
all_properties: dict[str, dict] = {}
seen_keys: set[tuple] = set()
counts = {"rm": 0, "hk": 0, "or": 0, "zp": 0}
total_dedup = 0
# Rightmove first (priority source)
for p in rm_props:
pid = p["id"]
if pid not in all_properties:
all_properties[pid] = p
seen_keys.add(_dedup_key(p))
counts["rm"] += 1
# Other sources (check for cross-source duplicates)
for source, props in [("hk", hk_props), ("or", or_props), ("zp", zp_props)]:
for p in props:
pid = p["id"]
key = _dedup_key(p)
if pid in all_properties or key in seen_keys:
total_dedup += 1
continue
all_properties[pid] = p
seen_keys.add(key)
counts[source] += 1
return all_properties, counts, total_dedup
# ---------------------------------------------------------------------------
# Checkpointing — save/resume partial results across crashes
# ---------------------------------------------------------------------------
def _checkpoint_meta_path():
return DATA_DIR / "checkpoint.json"
def _checkpoint_results_path(source: str, channel: str):
return DATA_DIR / f"checkpoint_{source}_{channel}.json"
def _save_checkpoint(
shuffled: list[str],
progress: _Progress,
source_results: dict[str, dict[str, list]],
active_sources: list[str],
) -> None:
"""Save per-source progress indices and partial results to disk.
Writes atomically (temp + rename) so a crash mid-write leaves the previous
checkpoint intact.
"""
snap = progress.snapshot()
meta = {
"seed": SEED,
"num_outcodes": len(shuffled),
"sources": {s: snap.get(s, 0) for s in active_sources},
"timestamp": time.time(),
}
# Write result files per source per channel
for source in active_sources:
results = source_results.get(source, {})
for ch_key in ("BUY", "RENT"):
props = results.get(ch_key, [])
path = _checkpoint_results_path(source, ch_key.lower())
tmp = path.with_suffix(".tmp")
try:
with open(tmp, "w") as f:
json.dump(props, f, default=str)
tmp.rename(path)
except Exception as e:
log.warning("Failed to write checkpoint %s: %s", path.name, e)
# Write metadata atomically
tmp = _checkpoint_meta_path().with_suffix(".tmp")
try:
with open(tmp, "w") as f:
json.dump(meta, f)
tmp.rename(_checkpoint_meta_path())
except Exception as e:
log.warning("Failed to write checkpoint metadata: %s", e)
return
total = sum(len(source_results.get(s, {}).get(ch, []))
for s in active_sources for ch in ("BUY", "RENT"))
log.info(
"Checkpoint saved: %s (%d properties)",
{s: snap.get(s, 0) for s in active_sources},
total,
)
def _load_checkpoint(
shuffled: list[str],
) -> tuple[dict[str, int], dict[str, dict[str, list]]] | None:
"""Load checkpoint if it exists and matches the current outcode list.
Returns (start_indices, loaded_results) or None if no valid checkpoint.
"""
path = _checkpoint_meta_path()
if not path.exists():
return None
try:
with open(path) as f:
meta = json.load(f)
except Exception:
log.warning("Checkpoint file corrupt, starting fresh")
_clear_checkpoint()
return None
if meta.get("seed") != SEED or meta.get("num_outcodes") != len(shuffled):
log.info("Checkpoint from different run configuration, discarding")
_clear_checkpoint()
return None
start_indices: dict[str, int] = {}
loaded_results: dict[str, dict[str, list]] = {}
for source, completed in meta.get("sources", {}).items():
start_indices[source] = completed
loaded_results[source] = {"BUY": [], "RENT": []}
for channel in ("buy", "rent"):
rpath = _checkpoint_results_path(source, channel)
if rpath.exists():
try:
with open(rpath) as f:
raw = json.load(f)
# Deduplicate by ID — concurrent workers (e.g. hk_worker's
# ThreadPoolExecutor) can cause in-flight outcodes to have
# results saved before their progress index is recorded.
# On resume those outcodes get re-scraped, duplicating results.
seen_ids: set[str] = set()
deduped: list[dict] = []
for p in raw:
pid = p.get("id")
if pid not in seen_ids:
seen_ids.add(pid)
deduped.append(p)
if len(deduped) < len(raw):
log.info(
"Checkpoint %s/%s: deduped %d%d (removed %d dupes)",
source, channel, len(raw), len(deduped),
len(raw) - len(deduped),
)
loaded_results[source][channel.upper()] = deduped
except Exception:
log.warning(
"Checkpoint results for %s/%s corrupt, restarting %s",
source, channel, source,
)
start_indices[source] = 0
loaded_results[source] = {"BUY": [], "RENT": []}
break
elapsed_since = time.time() - meta.get("timestamp", 0)
log.info(
"Resuming from checkpoint (saved %.0fm ago): %s",
elapsed_since / 60,
start_indices,
)
return start_indices, loaded_results
def _clear_checkpoint() -> None:
"""Remove all checkpoint files after successful completion."""
for path in DATA_DIR.glob("checkpoint*"):
try:
path.unlink()
except Exception:
pass
def run_scrape(
outcodes: list[str],
pc_index: PostcodeSpatialIndex,
pc_coords: dict[str, tuple[float, float]] | None = None,
) -> None:
"""Main scrape orchestrator — runs all sources in parallel threads.
Each source (Rightmove, home.co.uk, OpenRent, Zoopla) gets its own thread
that iterates all outcodes for both BUY and RENT channels. Results are
merged with cross-source deduplication after all workers complete.
"""
global status
with status_lock:
status.state = "running"
status.started_at = time.time()
status.finished_at = 0.0
status.errors = []
status.properties_buy = 0
status.properties_rent = 0
status.channel = ""
status.outcode = ""
_sync_gauges()
shuffled = list(outcodes)
random.seed(SEED)
random.shuffle(shuffled)
if not any([SCRAPE_RIGHTMOVE, SCRAPE_HOMECOUK, SCRAPE_OPENRENT, SCRAPE_ZOOPLA]):
log.warning("All scrapers disabled — nothing to do")
with status_lock:
status.state = "done"
status.finished_at = time.time()
_sync_gauges()
return
if not SCRAPE_RIGHTMOVE:
log.info("Rightmove scraping DISABLED (SCRAPE_RIGHTMOVE=false)")
if not SCRAPE_HOMECOUK:
log.info("home.co.uk scraping DISABLED (SCRAPE_HOMECOUK=false)")
homecouk_enabled.set(0)
if not SCRAPE_OPENRENT:
log.info("OpenRent scraping DISABLED (SCRAPE_OPENRENT=false)")
openrent_enabled.set(0)
if not SCRAPE_ZOOPLA:
log.info("Zoopla scraping DISABLED (SCRAPE_ZOOPLA=false)")
zoopla_enabled.set(0)
# Build postcode coords if needed for OpenRent/Zoopla
if (SCRAPE_OPENRENT or SCRAPE_ZOOPLA) and pc_coords is None:
pc_coords = build_postcode_coords()
# Per-source result containers: {channel_name: [properties]}
# Each list is only written by its owning source thread.
rm_results: dict[str, list] = {"BUY": [], "RENT": []}
hk_results: dict[str, list] = {"BUY": [], "RENT": []}
or_results: dict[str, list] = {"BUY": [], "RENT": []}
zp_results: dict[str, list] = {"BUY": [], "RENT": []}
progress = _Progress()
# --- Resume from checkpoint if available ---
start_indices: dict[str, int] = {}
checkpoint = _load_checkpoint(shuffled)
if checkpoint:
start_indices, loaded = checkpoint
source_to_results = {"rm": rm_results, "hk": hk_results, "or": or_results, "zp": zp_results}
for src, data in loaded.items():
if src in source_to_results:
for ch in ("BUY", "RENT"):
source_to_results[src][ch] = data.get(ch, [])
# Reassign in case references changed
rm_results = source_to_results["rm"]
hk_results = source_to_results["hk"]
or_results = source_to_results["or"]
zp_results = source_to_results["zp"]
# Pre-set progress for resumed sources
for src, idx in start_indices.items():
if idx > 0:
progress.update(src, idx)
# --- Source worker closures ---
# Each worker owns its client lifecycle and iterates all outcodes for both
# channels. On auth failure, it refreshes cookies and continues. On fatal
# failure, it marks itself as done and returns partial results.
def rm_worker():
rm_start = start_indices.get("rm", 0)
if rm_start > 0:
log.info("Rightmove resuming from outcode %d/%d", rm_start, len(shuffled))
client = make_client()
try:
for i, outcode in enumerate(shuffled):
if i < rm_start:
continue
try:
outcode_id = resolve_outcode_id(client, outcode)
except Exception as e:
log.error("Rightmove %s ID lookup: %s", outcode, e)
scrape_errors_total.labels(source="rightmove").inc()
progress.update("rm", i + 1)
time.sleep(DELAY_BETWEEN_OUTCODES)
continue
if not outcode_id:
log.debug("No Rightmove ID for %s, skipping", outcode)
progress.update("rm", i + 1)
time.sleep(DELAY_BETWEEN_OUTCODES)
continue
for ch_cfg in CHANNELS:
ch = ch_cfg["channel"]
try:
props = search_outcode(
client, outcode_id, outcode, ch_cfg, pc_index
)
rm_results[ch].extend(props)
except Exception as e:
log.error("Rightmove %s/%s: %s", outcode, ch, e)
scrape_errors_total.labels(source="rightmove").inc()
progress.update("rm", i + 1)
time.sleep(DELAY_BETWEEN_OUTCODES)
except Exception as e:
log.exception("Fatal Rightmove error: %s", e)
with status_lock:
status.errors.append(f"Fatal Rightmove: {e}")
finally:
client.close()
def hk_worker():
hk_result = load_homecouk_cookies()
if not hk_result:
log.info("home.co.uk DISABLED (no cookies available)")
homecouk_enabled.set(0)
progress.update("hk", len(shuffled))
return
hk_start = start_indices.get("hk", 0)
if hk_start > 0:
log.info("home.co.uk resuming from outcode %d/%d", hk_start, len(shuffled))
log.info(
"home.co.uk scraping ENABLED (concurrency=%d)", HOMECOUK_CONCURRENCY
)
homecouk_enabled.set(1)
# Shared state across pool threads
cookie_state = {
"cookies": hk_result[0],
"user_agent": hk_result[1],
"generation": 0,
}
cookie_lock = threading.Lock()
results_lock = threading.Lock()
completed_count = [hk_start]
disabled = [False]
_local = threading.local()
def _get_client():
"""Get or create a thread-local curl_cffi session."""
with cookie_lock:
gen = cookie_state["generation"]
cookies = cookie_state["cookies"]
ua = cookie_state["user_agent"]
if not hasattr(_local, "client") or _local.gen != gen:
if hasattr(_local, "client"):
try:
_local.client.close()
except Exception:
pass
_local.client = make_homecouk_client(cookies, ua)
_local.gen = gen
return _local.client
def _refresh_cookies():
"""Refresh cookies via FlareSolverr. Thread-safe with generation check."""
with cookie_lock:
pre_gen = cookie_state["generation"]
new = load_homecouk_cookies()
if not new:
return False
with cookie_lock:
if cookie_state["generation"] == pre_gen:
cookie_state["cookies"] = new[0]
cookie_state["user_agent"] = new[1]
cookie_state["generation"] += 1
cookie_refreshes_total.labels(result="success").inc()
log.info("home.co.uk cookies refreshed")
return True
def _scrape_outcode(outcode):
if disabled[0]:
return
client = _get_client()
for ch_cfg in CHANNELS:
ch = ch_cfg["channel"]
if disabled[0]:
return
try:
props = homecouk_search_outcode(
client, outcode, ch, pc_index
)
if props:
with results_lock:
hk_results[ch].extend(props)
log.info(
"home.co.uk %s: +%d properties", outcode, len(props)
)
except CookiesExpiredError:
log.warning(
"home.co.uk cookies expired — attempting refresh"
)
if _refresh_cookies():
client = _get_client()
try:
props = homecouk_search_outcode(
client, outcode, ch, pc_index
)
if props:
with results_lock:
hk_results[ch].extend(props)
log.info(
"home.co.uk %s: +%d properties",
outcode,
len(props),
)
except Exception as e:
log.error(
"home.co.uk %s/%s (after refresh): %s",
outcode,
ch,
e,
)
scrape_errors_total.labels(source="homecouk").inc()
else:
log.warning(
"Cookie refresh failed, disabling home.co.uk"
)
disabled[0] = True
homecouk_enabled.set(0)
cookie_refreshes_total.labels(result="failure").inc()
with status_lock:
status.errors.append(
"home.co.uk cookies expired and refresh failed"
)
return
except Exception as e:
log.error("home.co.uk %s/%s: %s", outcode, ch, e)
scrape_errors_total.labels(source="homecouk").inc()
with results_lock:
completed_count[0] += 1
progress.update("hk", completed_count[0])
time.sleep(DELAY_BETWEEN_OUTCODES)
try:
work = [oc for i, oc in enumerate(shuffled) if i >= hk_start]
with ThreadPoolExecutor(
max_workers=HOMECOUK_CONCURRENCY
) as pool:
list(pool.map(_scrape_outcode, work))
except Exception as e:
log.exception("Fatal home.co.uk error: %s", e)
with status_lock:
status.errors.append(f"Fatal home.co.uk: {e}")
if disabled[0]:
progress.update("hk", len(shuffled))
def or_worker():
or_result = load_openrent_cookies()
if not or_result:
log.info("OpenRent DISABLED (no cookies available)")
openrent_enabled.set(0)
progress.update("or", len(shuffled))
return
or_start = start_indices.get("or", 0)
if or_start > 0:
log.info("OpenRent resuming from outcode %d/%d", or_start, len(shuffled))
client = make_openrent_client(*or_result)
log.info("OpenRent scraping ENABLED")
openrent_enabled.set(1)
try:
for i, outcode in enumerate(shuffled):
if i < or_start:
continue
# OpenRent is RENT-only
try:
props = openrent_search_outcode(
client, outcode, pc_index, pc_coords
)
or_results["RENT"].extend(props)
if props:
log.info("OpenRent %s: +%d properties", outcode, len(props))
except WafChallengeError:
log.warning(
"OpenRent WAF cookies expired — attempting refresh"
)
client.close()
or_new = load_openrent_cookies()
if or_new:
client = make_openrent_client(*or_new)
log.info("OpenRent cookies refreshed, continuing")
cookie_refreshes_total.labels(result="success").inc()
else:
log.warning(
"Cookie refresh failed, disabling OpenRent"
)
openrent_enabled.set(0)
cookie_refreshes_total.labels(result="failure").inc()
with status_lock:
status.errors.append(
"OpenRent WAF cookies expired and refresh failed"
)
progress.update("or", len(shuffled))
return
except Exception as e:
log.error("OpenRent %s: %s", outcode, e)
scrape_errors_total.labels(source="openrent").inc()
progress.update("or", i + 1)
time.sleep(DELAY_BETWEEN_OUTCODES)
except Exception as e:
log.exception("Fatal OpenRent error: %s", e)
with status_lock:
status.errors.append(f"Fatal OpenRent: {e}")
finally:
try:
client.close()
except Exception:
pass
def zp_worker():
try:
browser, page = launch_zoopla_browser()
log.info("Zoopla scraping ENABLED (Camoufox browser launched)")
zoopla_enabled.set(1)
except TurnstileError:
log.warning("Zoopla Cloudflare Turnstile failed — disabling Zoopla")
zoopla_enabled.set(0)
progress.update("zp", len(shuffled))
return
except Exception as e:
log.warning("Zoopla browser launch failed: %s — disabling Zoopla", e)
zoopla_enabled.set(0)
progress.update("zp", len(shuffled))
return
zp_start = start_indices.get("zp", 0)
if zp_start > 0:
log.info("Zoopla resuming from outcode %d/%d", zp_start, len(shuffled))
try:
for i, outcode in enumerate(shuffled):
if i < zp_start:
continue
search_url = None
for ch_cfg in CHANNELS:
ch = ch_cfg["channel"]
# Build direct URL for second channel by swapping path
direct_url = None
if search_url:
if ch == "BUY":
direct_url = search_url.replace("/to-rent/", "/for-sale/")
else:
direct_url = search_url.replace("/for-sale/", "/to-rent/")
try:
props, result_url = zoopla_search_outcode(
page, outcode, ch, pc_index, pc_coords,
base_search_url=direct_url,
)
if result_url:
search_url = result_url
zp_results[ch].extend(props)
if props:
log.info("Zoopla %s: +%d properties", outcode, len(props))
except TurnstileError:
log.warning(
"Zoopla Turnstile challenge — relaunching browser"
)
try:
browser.close()
except Exception:
pass
try:
browser, page = launch_zoopla_browser()
log.info("Zoopla browser relaunched, continuing")
except Exception:
log.warning(
"Browser relaunch failed, disabling Zoopla"
)
zoopla_enabled.set(0)
with status_lock:
status.errors.append(
"Zoopla Cloudflare challenge failed and relaunch failed"
)
progress.update("zp", len(shuffled))
return
except Exception as e:
log.error("Zoopla %s/%s: %s", outcode, ch, e)
scrape_errors_total.labels(source="zoopla").inc()
progress.update("zp", i + 1)
time.sleep(DELAY_BETWEEN_OUTCODES)
except Exception as e:
log.exception("Fatal Zoopla error: %s", e)
with status_lock:
status.errors.append(f"Fatal Zoopla: {e}")
finally:
try:
browser.close()
except Exception:
pass
# --- Launch worker threads ---
active_sources: list[str] = []
threads: list[threading.Thread] = []
if SCRAPE_RIGHTMOVE:
threads.append(threading.Thread(target=rm_worker, name="scrape-rm", daemon=True))
active_sources.append("rm")
if SCRAPE_HOMECOUK:
threads.append(threading.Thread(target=hk_worker, name="scrape-hk", daemon=True))
active_sources.append("hk")
if SCRAPE_OPENRENT:
threads.append(threading.Thread(target=or_worker, name="scrape-or", daemon=True))
active_sources.append("or")
if SCRAPE_ZOOPLA:
threads.append(threading.Thread(target=zp_worker, name="scrape-zp", daemon=True))
active_sources.append("zp")
log.info(
"=== Starting scrape: %d outcodes, sources: %s ===",
len(shuffled),
", ".join(active_sources),
)
for t in threads:
t.start()
# --- Monitor progress while workers run ---
# Map source names to result dicts for checkpointing
source_results_map = {
"rm": rm_results, "hk": hk_results,
"or": or_results, "zp": zp_results,
}
scrape_start = time.time()
last_log = 0.0
last_checkpoint = time.time()
try:
while any(t.is_alive() for t in threads):
snap = progress.snapshot()
min_done = min(
(snap.get(s, 0) for s in active_sources), default=0
)
# Count properties across sources (safe: only one thread writes each list)
total_buy = sum(
len(r["BUY"]) for r in [rm_results, hk_results, or_results, zp_results]
)
total_rent = sum(
len(r["RENT"]) for r in [rm_results, hk_results, or_results, zp_results]
)
with status_lock:
status.outcodes_done = min_done
status.outcodes_total = len(shuffled)
status.properties_buy = total_buy
status.properties_rent = total_rent
status.rm_properties = len(rm_results["BUY"]) + len(rm_results["RENT"])
status.hk_properties = len(hk_results["BUY"]) + len(hk_results["RENT"])
status.or_properties = len(or_results["RENT"])
status.zp_properties = len(zp_results["BUY"]) + len(zp_results["RENT"])
_sync_gauges()
now = time.time()
# Log progress every 30 seconds
if now - last_log >= 30:
elapsed = now - scrape_start
per_source = ", ".join(
f"{s}:{snap.get(s, 0)}" for s in active_sources
)
log.info(
"Progress: %d/%d outcodes (%s), %d buy + %d rent props, %s elapsed",
min_done,
len(shuffled),
per_source,
total_buy,
total_rent,
_fmt_elapsed(elapsed),
)
last_log = now
# Save checkpoint periodically
if now - last_checkpoint >= CHECKPOINT_INTERVAL:
try:
_save_checkpoint(
shuffled, progress, source_results_map, active_sources,
)
except Exception as e:
log.warning("Checkpoint save failed: %s", e)
last_checkpoint = now
time.sleep(5)
except Exception as e:
log.exception("Monitor loop error: %s", e)
# Save final checkpoint before joining (in case merge/write fails)
try:
_save_checkpoint(shuffled, progress, source_results_map, active_sources)
except Exception:
pass
for t in threads:
t.join()
log.info("All source workers completed")
# --- Merge results per channel and write parquet ---
try:
for ch_cfg in CHANNELS:
ch = ch_cfg["channel"]
file_suffix = "buy" if ch == "BUY" else "rent"
merged, counts, total_dedup = _merge_channel(
rm_results[ch],
hk_results[ch],
or_results[ch],
zp_results[ch],
)
# Update cross-source dedup counter
ch_label = "buy" if ch == "BUY" else "rent"
if total_dedup:
cross_source_dedup_total.labels(channel=ch_label).inc(total_dedup)
deduped = list(merged.values())
output_path = DATA_DIR / f"online_listings_{file_suffix}.parquet"
write_parquet(deduped, output_path, channel=file_suffix)
with status_lock:
if ch == "BUY":
status.properties_buy = len(deduped)
else:
status.properties_rent = len(deduped)
_sync_gauges()
log.info(
"=== %s complete: %d unique (rm:%d hk:%d or:%d zp:%d, cross-dedup:%d) ===",
ch,
len(deduped),
counts["rm"],
counts["hk"],
counts["or"],
counts["zp"],
total_dedup,
)
# Scrape completed successfully — clear checkpoint
_clear_checkpoint()
with status_lock:
status.state = "done"
status.finished_at = time.time()
status.outcodes_done = len(shuffled)
_sync_gauges()
elapsed = status.finished_at - status.started_at
log.info(
"Scrape complete in %s — buy: %d, rent: %d",
_fmt_elapsed(elapsed),
status.properties_buy,
status.properties_rent,
)
# Trigger server data reload
if RELOAD_URL:
try:
log.info("Triggering server reload at %s", RELOAD_URL)
resp = httpx.post(RELOAD_URL, timeout=300)
if resp.is_success:
body = resp.json()
log.info(
"Server reload complete: %d rows, %d features, %dms",
body.get("rows", 0),
body.get("features", 0),
body.get("elapsed_ms", 0),
)
else:
log.warning(
"Server reload failed (%d): %s",
resp.status_code,
resp.text[:200],
)
except Exception as e:
log.warning("Server reload request failed: %s", e)
except Exception as e:
log.exception("Fatal scrape error during merge/write")
with status_lock:
status.state = "error"
status.errors.append(f"Fatal: {e}")
status.finished_at = time.time()
_sync_gauges()

View file

@ -1,37 +0,0 @@
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

View file

@ -1,183 +0,0 @@
import logging
from datetime import datetime
from pathlib import Path
import polars as pl
from constants import MAX_BEDROOMS, MAX_RENT_MONTHLY, MIN_RENT_MONTHLY
from transform import map_property_type, normalize_postcode, normalize_price
log = logging.getLogger("rightmove")
def write_parquet(properties: list[dict], path: Path, channel: str) -> None:
"""Write properties list to parquet with server-ready column names.
channel: "buy" or "rent"
"""
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 (OpenRent: "DD Month, YYYY", "Today")
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)
# Derive asking price / asking rent based on channel
# Zero prices indicate parsing failures or POA/auction listings — treat as null
if channel == "buy":
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
asking_rents = [None] * len(properties)
listing_statuses = ["For sale"] * len(properties)
else:
asking_prices = [None] * len(properties)
# Normalize to monthly, then apply sanity bounds. Rents outside
# [MIN_RENT_MONTHLY, MAX_RENT_MONTHLY] are almost always total-stay
# pricing (short lets), annual rents mislabelled as monthly, or £0
# placeholders — null them out rather than polluting aggregates.
rent_outliers = 0
asking_rents = []
for p in properties:
monthly = normalize_price(p["price"], p["price_frequency"])
if monthly < MIN_RENT_MONTHLY or monthly > MAX_RENT_MONTHLY:
rent_outliers += 1
asking_rents.append(None)
else:
asking_rents.append(monthly)
if rent_outliers:
log.warning(
"Nulled %d rent outliers outside [£%d, £%d]/month",
rent_outliers,
MIN_RENT_MONTHLY,
MAX_RENT_MONTHLY,
)
listing_statuses = ["For rent"] * 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,
"Asking rent (monthly)": asking_rents,
},
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,
"Asking rent (monthly)": pl.Int64,
},
)
# Derive asking price per sqm for buy listings
if channel == "buy":
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)

View file

@ -1,230 +0,0 @@
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 normalize_price(amount: int, frequency: str) -> int:
"""Normalise price to monthly for rentals (weekly × 52/12, yearly ÷ 12)."""
if frequency == "weekly":
return round(amount * 52 / 12)
if frequency == "yearly":
return round(amount / 12)
return amount
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
frequency = price_obj.get("frequency", "")
# Store raw price — normalization to monthly happens once in storage.py
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": 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", ""),
}

998
finder/uv.lock generated
View file

@ -1,998 +0,0 @@
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 = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "browserforge"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "apify-fingerprint-datapoints" },
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/6f/8975af88d203efd70cc69477ebac702babef38201d04621c9583f2508f25/browserforge-1.2.4.tar.gz", hash = "sha256:05686473793769856ebd3528c69071f5be0e511260993e8b2ba839863711a0c4", size = 36700, upload-time = "2026-02-03T02:52:09.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/35/ce962f738ae28ffce6293e7607b129075633e6bb185a5ab87e49246eedc2/browserforge-1.2.4-py3-none-any.whl", hash = "sha256:fb1c14e62ac09de221dcfc73074200269f697596c642cb200ceaab1127a17542", size = 37890, upload-time = "2026-02-03T02:52:08.745Z" },
]
[[package]]
name = "camoufox"
version = "0.4.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "browserforge" },
{ name = "click" },
{ name = "language-tags" },
{ name = "lxml" },
{ name = "numpy" },
{ name = "orjson" },
{ name = "platformdirs" },
{ name = "playwright" },
{ name = "pysocks" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "screeninfo" },
{ name = "tqdm" },
{ name = "typing-extensions" },
{ name = "ua-parser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/15/e0a1b586e354ea6b8d6612717bf4372aaaa6753444d5d006caf0bb116466/camoufox-0.4.11.tar.gz", hash = "sha256:0a2c9d24ac5070c104e7c2b125c0a3937f70efa416084ef88afe94c32a72eebe", size = 64409, upload-time = "2025-01-29T09:33:20.019Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/7b/a2f099a5afb9660271b3f20f6056ba679e7ab4eba42682266a65d5730f7e/camoufox-0.4.11-py3-none-any.whl", hash = "sha256:83864d434d159a7566990aa6524429a8d1a859cbf84d2f64ef4a9f29e7d2e5ff", size = 71628, upload-time = "2025-01-29T09:33:18.558Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
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 = "beautifulsoup4" },
{ name = "camoufox" },
{ name = "curl-cffi" },
{ name = "fake-useragent" },
{ name = "flask" },
{ name = "httpx" },
{ name = "lxml" },
{ name = "playwright" },
{ name = "playwright-stealth" },
{ name = "polars" },
{ name = "prometheus-client" },
]
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4" },
{ name = "camoufox", specifier = ">=0.4.11" },
{ name = "curl-cffi" },
{ name = "fake-useragent", specifier = ">=2.2.0" },
{ name = "flask" },
{ name = "httpx" },
{ name = "lxml" },
{ name = "playwright", specifier = ">=1.58.0" },
{ name = "playwright-stealth", specifier = ">=2.0.2" },
{ name = "polars" },
{ name = "prometheus-client" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[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 = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "language-tags"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/7e/b6a0efe4fee11e9742c1baaedf7c574084238a70b03c1d8eb2761383848f/language_tags-1.2.0.tar.gz", hash = "sha256:e934acba3e3dc85f867703eca421847a9ab7b7679b11b5d5cfd096febbf8bde6", size = 207901, upload-time = "2023-01-11T18:38:07.893Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/42/327554649ed2dd5ce59d3f5da176c7be20f9352c7c6c51597293660b7b08/language_tags-1.2.0-py3-none-any.whl", hash = "sha256:d815604622242fdfbbfd747b40c31213617fd03734a267f2e39ee4bd73c88722", size = 213449, upload-time = "2023-01-11T18:38:05.692Z" },
]
[[package]]
name = "lxml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "numpy"
version = "2.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" },
{ url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" },
{ url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" },
{ url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" },
{ url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" },
{ url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" },
{ url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" },
{ url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" },
{ url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" },
{ url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" },
{ url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" },
{ url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" },
{ url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" },
{ url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" },
{ url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" },
{ url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" },
{ url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" },
{ url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" },
{ url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" },
{ url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" },
{ url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" },
{ url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" },
{ url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" },
{ url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" },
{ url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" },
{ url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" },
{ url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" },
{ url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" },
{ url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" },
{ url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" },
{ url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" },
{ url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" },
{ url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
]
[[package]]
name = "orjson"
version = "3.11.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" },
{ url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" },
{ url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" },
{ url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" },
{ url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" },
{ url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" },
{ url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" },
{ url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" },
{ url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" },
{ url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" },
{ url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" },
{ url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" },
{ url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" },
{ url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" },
{ url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" },
{ url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" },
{ url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" },
{ url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" },
{ url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" },
{ url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" },
{ url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" },
{ url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" },
{ url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" },
{ url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" },
{ url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" },
{ url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" },
{ url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" },
{ url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" },
{ url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" },
{ url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" },
{ url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" },
{ url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" },
{ url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" },
{ url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" },
{ url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" },
{ url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" },
{ url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" },
{ url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" },
{ url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
]
[[package]]
name = "playwright"
version = "1.58.0"
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 = "playwright-stealth"
version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "playwright" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/ee/871901103c7b2a12070011fd4d978191f8f962837bf8bb51847274f528fa/playwright_stealth-2.0.2.tar.gz", hash = "sha256:ac57e51873190da5e653e03720e948c8f0a3d06b098f1d56763103d23ee48143", size = 24902, upload-time = "2026-02-13T02:36:25.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/30/f95f087f4b071611a7f63a2a0c9af4df3ac046dae2a693bfdacd70512867/playwright_stealth-2.0.2-py3-none-any.whl", hash = "sha256:37a5733f481b9c0ad602cf71491aa5a7c96c2a2fe4fa1e7ab764d2cd35520f2f", size = 33209, upload-time = "2026-02-13T02:36:26.334Z" },
]
[[package]]
name = "polars"
version = "1.39.0"
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 = "prometheus-client"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
]
[[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 = "soupsieve"
version = "2.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
[[package]]
name = "tqdm"
version = "4.67.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
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" },
]
[[package]]
name = "werkzeug"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
]

View file

@ -1,893 +0,0 @@
"""Zoopla (zoopla.co.uk) scraper — buy and rental properties.
Zoopla is behind Cloudflare Turnstile (managed interactive challenge), which
blocks all HTTP clients (curl_cffi, httpx) and even Playwright with stealth
patches. Only Camoufox (an anti-fingerprinting Firefox fork) passes reliably.
Zoopla uses Next.js App Router with React Server Components (RSC). Search
result data is server-rendered in an RSC stream, not available via
__NEXT_DATA__ or a JSON API. URL-based location slugs return 0 results
the working flow requires typing into the autocomplete input, selecting a
suggestion, and clicking Search.
Architecture:
Unlike the other scrapers which use HTTP clients per outcode, Zoopla keeps
a single Camoufox browser alive for the entire scrape. For each outcode, it:
1. Clears and types the outcode into the search input
2. Selects the first autocomplete suggestion
3. Clicks Search
4. Extracts listing data from the rendered DOM
5. Handles pagination via ?pn=N parameter
The browser session replaces the cookie/client pattern used by other scrapers.
"""
import logging
import re
import time
from constants import DELAY_BETWEEN_PAGES, MAX_BEDROOMS, PROPERTY_TYPE_MAP, ZOOPLA_BASE
from metrics import zoopla_errors_total, zoopla_pages_scraped, zoopla_properties_scraped
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."""
# 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/"], a[href*="/to-rent/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|to\s+(?:rent|let)|for\s+rent)/i);
if (ptMatch) property_type = ptMatch[1].trim();
else if (/\bstudio\s*(?:flat|apartment)?\s+(?:for\s+sale|to\s+(?:rent|let)|for\s+rent)/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/"], a[href*="/to-rent/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|to\s+(?:rent|let)|for\s+rent)/i);
if (ptMatch2) property_type = ptMatch2[1].trim();
else if (/\bstudio\s*(?:flat|apartment)?\s+(?:for\s+sale|to\s+(?:rent|let)|for\s+rent)/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 60 seconds.
Caller must close browser when done."""
from camoufox.pkgman import camoufox_path
# Verify camoufox is pre-installed — never download at runtime
camoufox_path(download_if_missing=False)
from camoufox.sync_api import Camoufox
log.info("Launching Camoufox browser for Zoopla...")
browser = Camoufox(headless=True).__enter__()
page = browser.new_page()
log.info("Navigating to Zoopla homepage...")
page.goto(f"{ZOOPLA_BASE}/", wait_until="domcontentloaded", timeout=60000)
# Wait for Cloudflare Turnstile to resolve.
# Try clicking the Turnstile checkbox if present (helps in some cases).
for i in range(20):
if "Just a moment" not in page.title():
break
# Attempt to click the Turnstile checkbox in the challenge iframe
for frame in page.frames:
if "challenges.cloudflare.com" in frame.url:
try:
iframe_el = page.query_selector('iframe[src*="challenges.cloudflare"]')
if iframe_el:
box = iframe_el.bounding_box()
if box:
page.mouse.click(box["x"] + 30, box["y"] + box["height"] / 2)
except Exception:
pass
break
time.sleep(3)
else:
page.close()
browser.close()
raise TurnstileError("Cloudflare Turnstile did not resolve after 60s")
log.info("Cloudflare passed — title: %s", page.title())
time.sleep(2)
# Dismiss cookie consent
page.evaluate(_DISMISS_COOKIES_JS)
time.sleep(1)
return browser, page
def _ensure_not_challenged(page) -> None:
"""Check if current page is a Cloudflare challenge and wait/raise."""
if "Just a moment" not in page.title():
return
log.warning("Cloudflare challenge detected mid-session, waiting...")
for i in range(20):
time.sleep(3)
if "Just a moment" not in page.title():
log.info("Cloudflare challenge resolved")
return
raise TurnstileError("Cloudflare re-challenge did not resolve")
# ---------------------------------------------------------------------------
# Search navigation
# ---------------------------------------------------------------------------
def _navigate_direct(page, url: str) -> bool:
"""Navigate directly to a Zoopla search URL (skipping the homepage flow).
Used to load the second channel (e.g., RENT after BUY) for the same outcode
by swapping the path component. Falls back gracefully returns False if
the page has no listings, so the caller can retry via the full search flow.
"""
try:
page.goto(url, wait_until="domcontentloaded", timeout=30000)
except Exception as e:
log.debug("Direct navigation failed: %s", e)
return False
_ensure_not_challenged(page)
# Wait for listing content to hydrate
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:
# Check if the page has any listings at all
has_listings = page.query_selector('a[href*="/details/"]')
if not has_listings:
return False
time.sleep(1.5)
return True
def _navigate_search(page, outcode: str, channel: str) -> bool:
"""Navigate to search results for an outcode via the homepage search flow.
Returns True if results were found, False if no results or navigation failed.
Raises TurnstileError if Cloudflare blocks us."""
# Navigate to homepage to reset search state
page.goto(f"{ZOOPLA_BASE}/", wait_until="domcontentloaded", timeout=30000)
time.sleep(0.5)
_ensure_not_challenged(page)
# Dismiss cookie consent (may reappear after navigation)
page.evaluate(_DISMISS_COOKIES_JS)
time.sleep(0.3)
# Select Buy/Rent tab
if channel == "RENT":
rent_tab = page.query_selector(
'button:has-text("Rent"), [role="tab"]:has-text("Rent")'
)
if rent_tab:
rent_tab.click()
time.sleep(0.2)
# Find and fill search input
search_input = page.query_selector(
'input[name="autosuggest-input"]'
) or page.query_selector('input[type="text"]')
if not search_input:
log.warning("Could not find search input on homepage")
return False
search_input.click()
time.sleep(0.1)
search_input.fill("")
search_input.type(outcode, delay=60)
time.sleep(1.2)
# Select first autocomplete suggestion
first_option = page.query_selector('[role="option"]')
if not first_option:
log.debug("No autocomplete suggestions for outcode %s", outcode)
return False
first_option.click()
time.sleep(0.2)
# Click search button
search_btn = page.query_selector('button:has-text("Search")')
if search_btn:
search_btn.click()
else:
search_input.press("Enter")
# Wait for results to load — try waiting for listings container, fall back to fixed wait
try:
page.wait_for_selector(
'[data-testid="regular-listings"], a[href*="/details/"]',
timeout=10000,
)
except Exception:
time.sleep(4)
_ensure_not_challenged(page)
# Wait for client-side hydration to populate listing content (prices, addresses).
# The structural container appears in server-rendered HTML before React hydrates
# the actual card content — extracting too early yields empty price/address fields.
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:
# Content never appeared — extraction will likely fail but let it try
log.debug("Listing content hydration wait timed out — prices may not have rendered")
time.sleep(2)
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)
zoopla_errors_total.labels(type="extract_failed").inc()
return []
def _paginate(page, total_results: int, channel: str) -> list[dict]:
"""Extract listings from all pages of search results.
Page 1 is already loaded. For subsequent pages, clicks the Next button
or navigates via URL parameter ?pn=N."""
all_listings = _extract_listings(page)
channel_label = "buy" if channel == "BUY" else "rent"
zoopla_pages_scraped.labels(channel=channel_label).inc()
if not all_listings or total_results <= len(all_listings):
return all_listings
seen_ids = {listing["id"] for listing in all_listings}
current_url = page.url
page_num = 2
while len(all_listings) < total_results and page_num <= MAX_PAGES_PER_OUTCODE:
time.sleep(DELAY_BETWEEN_PAGES)
# Try navigating via URL parameter
if "?" in current_url:
next_url = re.sub(r"[?&]pn=\d+", "", current_url)
separator = "&" if "?" in next_url else "?"
next_url = f"{next_url}{separator}pn={page_num}"
else:
next_url = f"{current_url}?pn={page_num}"
try:
page.goto(next_url, wait_until="domcontentloaded", timeout=30000)
_ensure_not_challenged(page)
# Wait for listing content instead of fixed sleep
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)
except TurnstileError:
raise
except Exception as e:
log.debug("Pagination navigation failed at page %d: %s", page_num, e)
break
page_listings = _extract_listings(page)
if not page_listings:
break
# Deduplicate within this outcode
new_count = 0
for listing in page_listings:
if listing["id"] not in seen_ids:
seen_ids.add(listing["id"])
all_listings.append(listing)
new_count += 1
zoopla_pages_scraped.labels(channel=channel_label).inc()
if new_count == 0:
break # No new listings on this page
page_num += 1
return all_listings
# ---------------------------------------------------------------------------
# Property transformation
# ---------------------------------------------------------------------------
# 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 _detect_rent_frequency(price_text: str) -> str:
"""Detect rent frequency from Zoopla price text.
Zoopla price elements contain text like '£1,500 pcm', '£350 pw',
'£18,000 pa'. Defaults to 'monthly' if no frequency indicator found.
Checks monthly indicators (pcm) BEFORE weekly (pw) because Zoopla cards
often display both monthly and weekly prices in the same text. When the
JS extraction falls back to full card text, checking pcm first ensures
the captured monthly price gets the correct frequency label.
"""
lower = price_text.lower()
if "pcm" in lower or "per month" in lower or "per calendar month" in lower:
return "monthly"
if "pw" in lower or "per week" in lower or "/w" in lower:
return "weekly"
if "pa" in lower or "per annum" in lower or "/y" in lower or "per year" in lower:
return "yearly"
# No indicator — default monthly (Zoopla standard)
return "monthly"
def transform_property(
raw: dict,
channel: str,
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
# Detect rent frequency from price text (e.g. "£1,500 pcm" vs "£350 pw")
if channel == "BUY":
frequency = ""
else:
price_text = raw.get("price_text", "")
frequency = _detect_rent_frequency(price_text)
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": 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,
channel: str,
pc_index: PostcodeSpatialIndex,
pc_coords: dict[str, tuple[float, float]],
base_search_url: str | 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.
If base_search_url is provided (from a previous channel search for the same
outcode), tries direct URL navigation first skipping the slow homepage
search flow. Falls back to full navigation if direct fails.
Returns (properties, search_url) where search_url can be passed to the next
channel call for this outcode.
Raises TurnstileError if Cloudflare blocks us mid-session.
"""
navigated = False
if base_search_url:
navigated = _navigate_direct(page, base_search_url)
if navigated:
log.debug("Zoopla %s %s: used direct URL navigation", outcode, channel)
if not navigated:
if not _navigate_search(page, outcode, channel):
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), channel)
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, channel, total_results,
)
return [], None
channel_label = "buy" if channel == "BUY" else "rent"
properties = []
dropped = 0
for raw in raw_listings:
transformed = transform_property(raw, channel, pc_index, pc_coords, search_outcode=outcode)
if transformed:
properties.append(transformed)
zoopla_properties_scraped.labels(channel=channel_label).inc()
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, channel, 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, channel, dropped, len(raw_listings),
)
return properties, page.url

View file

@ -288,7 +288,7 @@ export default function App() {
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }}
initialFilters={urlState.filters || {}}
initialViewState={initialViewState}
initialPOICategories={urlState.poiCategories || new Set()}
initialTab={urlState.tab || 'area'}
@ -393,7 +393,7 @@ export default function App() {
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={mapUrlState.filters || { 'Listing status': ['Historical sale'] }}
initialFilters={mapUrlState.filters || {}}
initialViewState={initialViewState}
initialPOICategories={mapUrlState.poiCategories || new Set()}
initialTab={mapUrlState.tab || 'area'}

View file

@ -127,8 +127,6 @@ function NotesInput({ value, onSave }: { value: string; onSave: (notes: string)
}
function formatPropertyPrice(data: SavedPropertyData): string | null {
if (data.askingPrice) return `£${formatNumber(data.askingPrice)}`;
if (data.askingRent) return `£${formatNumber(data.askingRent)}/mo`;
if (data.estimatedPrice) return `${formatNumber(data.estimatedPrice)}`;
if (data.price) return `£${formatNumber(data.price)}`;
return null;
@ -141,7 +139,6 @@ function formatPropertyDetails(
const parts: string[] = [];
if (data.propertySubType) parts.push(data.propertySubType);
else if (data.propertyType) parts.push(data.propertyType);
if (data.bedrooms) parts.push(`${data.bedrooms} ${t('savedPage.bed')}`);
if (data.floorArea) parts.push(`${formatNumber(data.floorArea)}`);
if (data.energyRating) parts.push(`${t('savedPage.epc')} ${data.energyRating}`);
return parts.join(' · ');
@ -449,16 +446,6 @@ function SavedPropertiesTab({
<TrashIcon className="w-4 h-4" />
</button>
</div>
{prop.data.listingUrl && (
<a
href={prop.data.listingUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-2 block text-center px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
{t('savedPage.viewListing')} &rarr;
</a>
)}
</div>
</div>
);
@ -601,7 +588,9 @@ function InviteTable({
<table className="w-full table-fixed text-sm">
<thead>
<tr className="border-b border-warm-200 dark:border-warm-700 text-left">
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">{t('invitesPage.link')}</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">
{t('invitesPage.link')}
</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">
{t('invitesPage.status')}
</th>
@ -754,7 +743,9 @@ export function InvitesPage({ user }: { user: AuthUser }) {
{(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => (
<div key={type} className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
{type === 'admin' ? t('invitesPage.inviteAdminLabel') : t('invitesPage.inviteReferralLabel')}
{type === 'admin'
? t('invitesPage.inviteAdminLabel')
: t('invitesPage.inviteReferralLabel')}
</p>
{inviteUrl[type] ? (
<div className="flex items-center gap-2">
@ -783,7 +774,9 @@ export function InvitesPage({ user }: { user: AuthUser }) {
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
>
{creatingInvite[type] && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{type === 'admin' ? t('invitesPage.generateFreeInvite') : t('invitesPage.generateReferralLink')}
{type === 'admin'
? t('invitesPage.generateFreeInvite')
: t('invitesPage.generateReferralLink')}
</button>
)}
{inviteError[type] && (
@ -845,7 +838,9 @@ export default function AccountPage({
{/* Email */}
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">{t('accountPage.emailLabel')}</p>
<p className="text-sm text-warm-500 dark:text-warm-400">
{t('accountPage.emailLabel')}
</p>
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
</div>
</div>
@ -853,7 +848,9 @@ export default function AccountPage({
{/* Subscription */}
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">{t('accountPage.subscriptionLabel')}</p>
<p className="text-sm text-warm-500 dark:text-warm-400">
{t('accountPage.subscriptionLabel')}
</p>
<span
className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}
>

View file

@ -68,23 +68,24 @@ export default function HomePage({
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
<div>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
{t('home.heroTitle1')} <span className="text-teal-400">{t('home.heroTitle2')}</span>.
{t('home.heroTitle1')} <span className="text-teal-400">{t('home.heroTitle2')}</span>
.
<br />
{t('home.heroTitle3')}
</h1>
<p className="text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
<p className="text-base md:text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
</p>
<p className="text-lg text-warm-400 mb-8 max-w-xl">
<p className="text-base md:text-lg text-warm-400 mb-8 max-w-xl">
{t('home.heroDescription')}
</p>
<div className="flex flex-wrap items-center gap-4 mb-10">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
onOpenDashboard();
}}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center"
>
{t('home.exploreTheMap')}
</button>
@ -113,12 +114,12 @@ export default function HomePage({
};
requestAnimationFrame(step);
}}
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base"
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center"
>
{t('home.seeTheDifference')}
</button>
</div>
<div className="flex flex-wrap gap-x-12 gap-y-4 pt-3 border-t border-white/10">
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} />
@ -132,7 +133,9 @@ export default function HomePage({
<div className="text-sm text-warm-400">{t('home.statFilters')}</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">{t('home.statEvery')}</div>
<div className="text-2xl md:text-3xl font-bold text-white">
{t('home.statEvery')}
</div>
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
</div>
</div>
@ -142,30 +145,26 @@ export default function HomePage({
</div>
{/* Our philosophy */}
<div className="px-6 md:px-12 lg:px-20 pt-20 pb-4">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6">
<div className="px-6 md:px-12 lg:px-20 pt-12 md:pt-20 pb-4">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6">
{t('home.ourPhilosophy')}
</h2>
<div className="space-y-4 text-lg md:text-xl leading-relaxed text-warm-700 dark:text-warm-300">
<p>
{t('home.philosophyP1')}
</p>
<p>
{t('home.philosophyP2')}
</p>
<p>{t('home.philosophyP1')}</p>
<p>{t('home.philosophyP2')}</p>
</div>
</div>
{/* How to use it + Comparison table (two columns) */}
<div id="how-it-works" className="max-w-7xl mx-auto px-6 pt-10 pb-2">
<div ref={whyRef} className="fade-in-section">
<div className="grid lg:grid-cols-[2fr_3fr] gap-12 items-start">
<div className="grid lg:grid-cols-[2fr_3fr] gap-8 lg:gap-12 items-start">
{/* Left: How to use it */}
<div>
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6 md:mb-10">
{t('home.howToUseIt')}
</h2>
<div className="space-y-8">
<div className="space-y-6 md:space-y-8">
{[
{ title: t('home.howStep1Title'), desc: t('home.howStep1Desc') },
{ title: t('home.howStep2Title'), desc: t('home.howStep2Desc') },
@ -190,11 +189,11 @@ export default function HomePage({
</div>
{/* Right: Comparison table */}
<div id="comparison">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6 md:mb-10">
{t('home.othersVs')}{' '}
<span className="inline-flex items-baseline gap-3 whitespace-nowrap">
<span className="inline-flex items-baseline gap-2 md:gap-3">
{t('header.appName')}{' '}
<LogoIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
<LogoIcon className="w-6 h-6 md:w-8 md:h-8 text-teal-600 dark:text-teal-400" />
</span>
</h2>
<div className="overflow-x-auto rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 shadow-sm">
@ -202,9 +201,6 @@ export default function HomePage({
<thead>
<tr className="border-b border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
<th className="px-2 md:px-5 py-3 md:py-4 text-xs md:text-sm font-bold text-navy-950 dark:text-warm-100" />
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
{t('home.listingPortals')}
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
{t('home.checkMyPostcode')}
</th>
@ -218,10 +214,30 @@ export default function HomePage({
</thead>
<tbody>
{[
{ feature: t('home.compSearchWithout'), subtitle: t('home.compSearchWithoutSub'), listings: false, postcode: false, guides: false },
{ feature: t('home.compAreaData'), subtitle: t('home.compAreaDataSub'), listings: false, postcode: true, guides: true },
{ feature: t('home.compPropertyData'), subtitle: t('home.compPropertyDataSub'), listings: true, postcode: false, guides: false },
{ feature: t('home.compFilters'), subtitle: t('home.compFiltersSub'), listings: false, postcode: false, guides: false },
{
feature: t('home.compSearchWithout'),
subtitle: t('home.compSearchWithoutSub'),
postcode: false,
guides: false,
},
{
feature: t('home.compAreaData'),
subtitle: t('home.compAreaDataSub'),
postcode: true,
guides: true,
},
{
feature: t('home.compPropertyData'),
subtitle: t('home.compPropertyDataSub'),
postcode: false,
guides: false,
},
{
feature: t('home.compFilters'),
subtitle: t('home.compFiltersSub'),
postcode: false,
guides: false,
},
].map((row, i, arr) => (
<tr
key={i}
@ -239,7 +255,7 @@ export default function HomePage({
</div>
)}
</td>
{[row.listings, row.postcode, row.guides].map((has, j) => (
{[row.postcode, row.guides].map((has, j) => (
<td
key={j}
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg ${has ? 'text-green-500' : 'text-red-500'}`}
@ -261,7 +277,7 @@ export default function HomePage({
</div>
{/* The real cost CTA */}
<div className="max-w-4xl mx-auto px-6 pt-20 pb-12">
<div className="max-w-4xl mx-auto px-6 pt-12 md:pt-20 pb-12">
<div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-4 leading-snug">
{t('home.ctaTitle')}
@ -274,7 +290,7 @@ export default function HomePage({
trackEvent('CTA Click', { location: 'bottom', label: 'explore_map' });
onOpenDashboard();
}}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
className="w-full sm:w-auto px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
{t('home.exploreTheMap')}
</button>
@ -287,4 +303,3 @@ export default function HomePage({
</div>
);
}

View file

@ -202,7 +202,9 @@ export default function InvitePage({
<span className="text-[96px] leading-none font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl">{t('upgrade.once')}</span>
<span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl">
{t('upgrade.once')}
</span>
</div>
)}
<p className="text-warm-600 dark:text-warm-400 text-3xl">

View file

@ -14,38 +14,122 @@ interface DataSourceDef {
}
const DATA_SOURCE_DEFS: DataSourceDef[] = [
{ id: 'price-paid', url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads', license: 'Open Government Licence v3.0' },
{ id: 'epc', url: 'https://epc.opendatacommunities.org/downloads/domestic', license: 'Open Government Licence v3.0', optOutUrl: 'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure' },
{ id: 'nspl', url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data', license: 'Open Government Licence v3.0' },
{ id: 'iod', url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025', license: 'Open Government Licence v3.0' },
{ id: 'ethnicity', url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data', license: 'Open Government Licence v3.0' },
{
id: 'price-paid',
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
license: 'Open Government Licence v3.0',
},
{
id: 'epc',
url: 'https://epc.opendatacommunities.org/downloads/domestic',
license: 'Open Government Licence v3.0',
optOutUrl:
'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
},
{
id: 'nspl',
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
license: 'Open Government Licence v3.0',
},
{
id: 'iod',
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
license: 'Open Government Licence v3.0',
},
{
id: 'ethnicity',
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
license: 'Open Government Licence v3.0',
},
{ id: 'crime', url: 'https://data.police.uk/data/', license: 'Open Government Licence v3.0' },
{ id: 'osm-pois', url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf', license: 'Open Data Commons Open Database License (ODbL)' },
{ id: 'os-open-greenspace', url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace', license: 'Open Government Licence v3.0' },
{ id: 'naptan', url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf', license: 'Open Government Licence v3.0' },
{ id: 'noise', url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs', license: 'Open Government Licence v3.0' },
{ id: 'ofsted', url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes', license: 'Open Government Licence v3.0' },
{ id: 'broadband', url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025', license: 'Open Government Licence v3.0' },
{ id: 'council-tax', url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026', license: 'Open Government Licence v3.0' },
{ id: 'ons-rental', url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland', license: 'Open Government Licence v3.0' },
{
id: 'osm-pois',
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
license: 'Open Data Commons Open Database License (ODbL)',
},
{
id: 'os-open-greenspace',
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
license: 'Open Government Licence v3.0',
},
{
id: 'naptan',
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
license: 'Open Government Licence v3.0',
},
{
id: 'noise',
url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs',
license: 'Open Government Licence v3.0',
},
{
id: 'ofsted',
url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes',
license: 'Open Government Licence v3.0',
},
{
id: 'broadband',
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
license: 'Open Government Licence v3.0',
},
{
id: 'council-tax',
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
license: 'Open Government Licence v3.0',
},
{
id: 'ons-rental',
url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland',
license: 'Open Government Licence v3.0',
},
{
id: 'election-results',
url: 'https://electionresults.parliament.uk/general-elections/6',
license: 'Open Parliament Licence v3.0',
},
];
// Maps data source id → [nameKey, originKey, useKey] in en.ts learnPage section
const DS_KEYS: Record<string, [string, string, string]> = {
'price-paid': ['learnPage.dsPricePaidName', 'learnPage.dsPricePaidOrigin', 'learnPage.dsPricePaidUse'],
'epc': ['learnPage.dsEpcName', 'learnPage.dsEpcOrigin', 'learnPage.dsEpcUse'],
'nspl': ['learnPage.dsNsplName', 'learnPage.dsNsplOrigin', 'learnPage.dsNsplUse'],
'iod': ['learnPage.dsIodName', 'learnPage.dsIodOrigin', 'learnPage.dsIodUse'],
'ethnicity': ['learnPage.dsEthnicityName', 'learnPage.dsEthnicityOrigin', 'learnPage.dsEthnicityUse'],
'crime': ['learnPage.dsCrimeName', 'learnPage.dsCrimeOrigin', 'learnPage.dsCrimeUse'],
'price-paid': [
'learnPage.dsPricePaidName',
'learnPage.dsPricePaidOrigin',
'learnPage.dsPricePaidUse',
],
epc: ['learnPage.dsEpcName', 'learnPage.dsEpcOrigin', 'learnPage.dsEpcUse'],
nspl: ['learnPage.dsNsplName', 'learnPage.dsNsplOrigin', 'learnPage.dsNsplUse'],
iod: ['learnPage.dsIodName', 'learnPage.dsIodOrigin', 'learnPage.dsIodUse'],
ethnicity: [
'learnPage.dsEthnicityName',
'learnPage.dsEthnicityOrigin',
'learnPage.dsEthnicityUse',
],
crime: ['learnPage.dsCrimeName', 'learnPage.dsCrimeOrigin', 'learnPage.dsCrimeUse'],
'osm-pois': ['learnPage.dsOsmName', 'learnPage.dsOsmOrigin', 'learnPage.dsOsmUse'],
'os-open-greenspace': ['learnPage.dsGreenspaceName', 'learnPage.dsGreenspaceOrigin', 'learnPage.dsGreenspaceUse'],
'naptan': ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
'noise': ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
'ofsted': ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
'broadband': ['learnPage.dsBroadbandName', 'learnPage.dsBroadbandOrigin', 'learnPage.dsBroadbandUse'],
'council-tax': ['learnPage.dsCouncilTaxName', 'learnPage.dsCouncilTaxOrigin', 'learnPage.dsCouncilTaxUse'],
'os-open-greenspace': [
'learnPage.dsGreenspaceName',
'learnPage.dsGreenspaceOrigin',
'learnPage.dsGreenspaceUse',
],
naptan: ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
noise: ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
ofsted: ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
broadband: [
'learnPage.dsBroadbandName',
'learnPage.dsBroadbandOrigin',
'learnPage.dsBroadbandUse',
],
'council-tax': [
'learnPage.dsCouncilTaxName',
'learnPage.dsCouncilTaxOrigin',
'learnPage.dsCouncilTaxUse',
],
'ons-rental': ['learnPage.dsRentalName', 'learnPage.dsRentalOrigin', 'learnPage.dsRentalUse'],
'election-results': [
'learnPage.dsElectionName',
'learnPage.dsElectionOrigin',
'learnPage.dsElectionUse',
],
};
function FAQItemCard({ question, answer }: { question: string; answer: string }) {
@ -207,53 +291,53 @@ export default function LearnPage() {
const keys = DS_KEYS[source.id];
const [nameKey, originKey, useKey] = keys;
return (
<div
key={source.id}
id={source.id}
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{tDynamic(nameKey)}
</h2>
<span className="text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded text-right">
{source.license}
</span>
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
{t('learnPage.source')} {tDynamic(originKey)}
</p>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
{tDynamic(useKey)}
</p>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline break-all"
<div
key={source.id}
id={source.id}
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
>
{source.url}
</a>
{source.optOutUrl && (
<div className="mt-2">
<a
href={source.optOutUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
{t('learnPage.optOut')}
</a>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{tDynamic(nameKey)}
</h2>
<span className="text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded text-right">
{source.license}
</span>
</div>
)}
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
{t('learnPage.source')} {tDynamic(originKey)}
</p>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
{tDynamic(useKey)}
</p>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline break-all"
>
{source.url}
</a>
{source.optOutUrl && (
<div className="mt-2">
<a
href={source.optOutUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
{t('learnPage.optOut')}
</a>
</div>
)}
</div>
);
})}
</div>
@ -308,9 +392,7 @@ export default function LearnPage() {
</>
) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.faqIntro')}
</p>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
<div className="space-y-8">
{FAQ_SECTIONS.map((section) => (
<div key={section.title}>
@ -328,9 +410,7 @@ export default function LearnPage() {
</div>
) : (
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.supportIntro')}
</p>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
<a

View file

@ -53,8 +53,19 @@ export default memo(function AiFilterInput({
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [expanded, setExpanded] = useState(false);
const exampleQueries = useMemo(() => [t('aiFilter.example1'), t('aiFilter.example2'), t('aiFilter.example3')], [t]);
const loadingMessages = useMemo(() => [t('aiFilter.analysing'), t('aiFilter.searchingDestinations'), t('aiFilter.generatingFilters'), t('aiFilter.refiningResults')], [t]);
const exampleQueries = useMemo(
() => [t('aiFilter.example1'), t('aiFilter.example2'), t('aiFilter.example3')],
[t]
);
const loadingMessages = useMemo(
() => [
t('aiFilter.analysing'),
t('aiFilter.searchingDestinations'),
t('aiFilter.generatingFilters'),
t('aiFilter.refiningResults'),
],
[t]
);
const loadingMessage = useLoadingMessage(loading, loadingMessages);
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -147,7 +158,9 @@ export default memo(function AiFilterInput({
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<div className="flex items-center gap-1.5 mb-1.5">
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">{t('aiFilter.aiSearch')}</span>
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">
{t('aiFilter.aiSearch')}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500">
{t('aiFilter.describeHint')}
</span>

View file

@ -99,7 +99,9 @@ export default function AreaPane({
{isPostcode ? hexagonId : t('areaPane.areaStatistics')}
</h2>
{isPostcode && (
<span className="text-xs text-warm-500 dark:text-warm-400">{t('common.postcode')}</span>
<span className="text-xs text-warm-500 dark:text-warm-400">
{t('common.postcode')}
</span>
)}
</div>
{loading && stats && (
@ -112,7 +114,11 @@ export default function AreaPane({
</p>
)}
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
{t('areaPane.statsFor', { type: isPostcode ? t('common.postcode').toLowerCase() : t('common.area').toLowerCase() })}
{t('areaPane.statsFor', {
type: isPostcode
? t('common.postcode').toLowerCase()
: t('common.area').toLowerCase(),
})}
{Object.keys(filters).length > 0 ? t('areaPane.matchingFilters') : ''}
</p>
{stats && stats.count > 0 && (
@ -150,7 +156,9 @@ export default function AreaPane({
return uniqueYears.size > 1;
})() && (
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">{t('areaPane.priceHistory')}</span>
<span className="text-xs text-warm-700 dark:text-warm-300">
{t('areaPane.priceHistory')}
</span>
<PriceHistoryChart points={stats.price_history} />
</div>
)}
@ -181,8 +189,7 @@ export default function AreaPane({
/>
{expanded && (
<div className="px-3 py-2 space-y-3">
{stackedCharts
? stackedCharts.map((chart) => {
{stackedCharts?.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
@ -197,9 +204,22 @@ export default function AreaPane({
? aggregateStats.mean
: segments.reduce((sum, s) => sum + s.value, 0);
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
// Use rateFeature (e.g. per-1k) for display if available
const rateStats = chart.rateFeature
? numericByName.get(chart.rateFeature)
: undefined;
const displayValue = rateStats ? rateStats.mean : total;
// Use rateFeature for info popup and national average when available
const infoFeatureName = chart.rateFeature ?? chart.feature;
const featureMeta = infoFeatureName
? globalFeatureByName.get(infoFeatureName)
: undefined;
const globalMean =
featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
if (total === 0) return null;
@ -220,17 +240,30 @@ export default function AreaPane({
{ts(chart.label)}
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(total)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
<div className="text-right shrink-0">
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(displayValue)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
{globalMean != null && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
</div>
)}
</div>
</div>
<StackedBarChart segments={segments} total={total} />
</div>
);
})
: group.features
.filter((f) => !stackedEnumFeatureNames.has(f.name))
})}
{(() => {
const stackedFeatureNames = new Set<string>(
stackedCharts?.flatMap((c) =>
[c.feature, c.rateFeature, ...c.components].filter((s): s is string => Boolean(s))
) ?? []
);
return group.features
.filter((f) => !stackedFeatureNames.has(f.name) && !stackedEnumFeatureNames.has(f.name))
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
@ -281,19 +314,25 @@ export default function AreaPane({
}
if (enumStats) {
const globalFeature = globalFeatureByName.get(feature.name);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart counts={enumStats.counts} />
<EnumBarChart
counts={enumStats.counts}
globalCounts={globalFeature?.counts}
featureName={feature.name}
/>
</div>
);
}
return null;
})}
});
})()}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)

View file

@ -1,23 +1,77 @@
export default function EnumBarChart({ counts }: { counts: Record<string, number> }) {
import { getEnumValueColor } from '../../lib/consts';
export default function EnumBarChart({
counts,
globalCounts,
featureName,
}: {
counts: Record<string, number>;
globalCounts?: Record<string, number>;
featureName?: string;
}) {
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
// When global counts are available, normalize both to percentages for comparison
const globalTotal = globalCounts
? Object.values(globalCounts).reduce((sum, c) => sum + c, 0)
: 0;
const hasGlobal = globalCounts && globalTotal > 0;
// Compute max percentage across both datasets for consistent bar scaling
const maxPct = entries.reduce((max, [label, count]) => {
const localPct = localTotal > 0 ? count / localTotal : 0;
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
return Math.max(max, localPct, globalPct);
}, 0);
// Fallback to raw count scaling when no global data
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
return (
<div className="space-y-1 mt-1">
{entries.map(([label, count]) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{label}
</span>
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
<div
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
style={{ width: `${(count / maxCount) * 100}%` }}
/>
{entries.map(([label, count]) => {
const localPct = localTotal > 0 ? count / localTotal : 0;
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
const localWidth = hasGlobal
? maxPct > 0
? (localPct / maxPct) * 100
: 0
: (count / maxCount) * 100;
const globalWidth = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
const overrideColor = featureName ? getEnumValueColor(featureName, label) : null;
const barStyle = overrideColor
? `rgb(${overrideColor[0]},${overrideColor[1]},${overrideColor[2]})`
: undefined;
return (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{label}
</span>
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden relative">
{hasGlobal && (
<div
className="absolute inset-y-0 left-0 bg-warm-300/60 dark:bg-warm-600/60 rounded"
style={{ width: `${globalWidth}%` }}
/>
)}
<div
className={
barStyle ? 'h-full rounded relative' : 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
}
style={{ width: `${localWidth}%`, ...(barStyle ? { backgroundColor: barStyle } : {}) }}
/>
</div>
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">
{count}
</span>
</div>
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
</div>
))}
);
})}
</div>
);
}

View file

@ -30,8 +30,8 @@ export default function ExternalSearchLinks({
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
[location, filters, rightmoveLocationId]
);
const radiusMiles = location.isPostcode ? 0.25 : (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = `${radiusMiles}mi radius`;
const radiusMiles = location.isPostcode ? 0 : (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = radiusMiles === 0 ? t('externalSearch.exact') : `${radiusMiles}mi radius`;
if (!urls) return null;
@ -61,11 +61,6 @@ export default function ExternalSearchLinks({
<a href={urls.zoopla} target="_blank" rel="noopener noreferrer" className={linkClass}>
Zoopla
</a>
{urls.openrent && (
<a href={urls.openrent} target="_blank" rel="noopener noreferrer" className={linkClass}>
OpenRent
</a>
)}
</div>
</div>
);

View file

@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { useTravelModes } from '../../hooks/useTravelModes';
import { SearchInput } from '../ui/SearchInput';
@ -15,9 +16,8 @@ import { IconButton } from '../ui/IconButton';
import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
import {
TRANSPORT_MODES,
MODE_LABELS,
MODE_DESCRIPTIONS,
MODE_ICONS,
useTranslatedModes,
type TransportMode,
type TravelTimeEntry,
} from '../../hooks/useTravelTime';
@ -34,7 +34,6 @@ interface FeatureBrowserProps {
travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void;
isLicensed: boolean;
onUpgradeClick?: () => void;
}
export default function FeatureBrowser({
@ -49,8 +48,9 @@ export default function FeatureBrowser({
travelTimeEntries: _travelTimeEntries,
onAddTravelTimeEntry,
isLicensed,
onUpgradeClick,
}: FeatureBrowserProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [travelInfoMode, setTravelInfoMode] = useState<TransportMode | null>(null);
@ -102,9 +102,13 @@ export default function FeatureBrowser({
return (
<>
<div className="shrink-0 p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('filters.searchFeatures')}
/>
</div>
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
<div>
{mergedGrouped.map((group) => {
const isExpanded = isSearching || isGroupExpanded(group.name);
return (
@ -158,24 +162,24 @@ export default function FeatureBrowser({
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
{MODE_LABELS[mode]}
{modes.label(mode)}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
{MODE_DESCRIPTIONS[mode]}
{modes.desc(mode)}
</span>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<IconButton
onClick={() => setTravelInfoMode(mode)}
title="Feature info"
title={t('filters.featureInfo')}
size="md"
>
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
<button
onClick={() => onAddTravelTimeEntry(mode)}
title={`Add ${MODE_LABELS[mode]} travel time`}
title={t('travel.addTravelTime', { mode: modes.label(mode) })}
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
>
<PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} />
@ -192,45 +196,15 @@ export default function FeatureBrowser({
{mergedGrouped.length === 0 ? (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={search ? 'No matching features' : 'All features are active'}
description={
search ? 'Try a different search term' : 'Remove a filter to see available features'
}
title={search ? t('filters.noMatchingFeatures') : t('filters.allFeaturesActive')}
description={search ? t('filters.tryDifferentSearch') : t('filters.removeFilterHint')}
className="px-3 py-4"
/>
) : isLicensed ? (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
Choose the filters that matter to you. The map updates as you go.
{t('filters.chooseFilters')}
</p>
) : (
<div className="mt-auto flex flex-col items-center px-5 pt-6 pb-0">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
See crime, schools, noise, broadband, and 50+ more filters across all of England.
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
One-time payment, lifetime access.
</p>
<button
onClick={onUpgradeClick}
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
Upgrade to full map
</button>
<svg
viewBox="0 120 1600 230"
className="w-full mt-4 block shrink-0"
preserveAspectRatio="xMidYMax meet"
>
<path
d="M0,350 C400,150 1200,150 1600,350 Z"
className="fill-green-500 dark:fill-green-600"
/>
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
<image href="/house.png" x="735" y="110" width="130" height="120" />
</svg>
</div>
)}
) : null}
</div>
{infoFeature && (
<FeatureInfoPopup

View file

@ -7,7 +7,12 @@ import { ChevronIcon, CloseIcon, LightbulbIcon, SpinnerIcon } from '../ui/icons'
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import type { FeatureMeta, FeatureFilters } from '../../types';
import { formatFilterValue, formatNumber, parseInputValue, buildPercentileScale } from '../../lib/format';
import {
formatFilterValue,
formatNumber,
parseInputValue,
buildPercentileScale,
} from '../../lib/format';
import type { PercentileScale } from '../../lib/format';
import InfoPopup from '../ui/InfoPopup';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
@ -25,8 +30,6 @@ import {
travelFieldKey,
} from '../../hooks/useTravelTime';
type ListingType = 'historical' | 'buy' | 'rent';
function EditableLabel({
value,
formatted,
@ -186,7 +189,13 @@ interface FiltersProps {
travelTimeEntries: TravelTimeEntry[];
onTravelTimeAddEntry: (mode: TransportMode) => void;
onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (index: number, slug: string, label: string, lat: number, lon: number) => void;
onTravelTimeSetDestination: (
index: number,
slug: string,
label: string,
lat: number,
lon: number
) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
@ -199,7 +208,6 @@ interface FiltersProps {
isLoggedIn: boolean;
onLoginRequired: () => void;
isLicensed: boolean;
isAdmin: boolean;
onUpgradeClick?: () => void;
onResetTutorial?: () => void;
filterImpacts?: Record<string, number>;
@ -241,7 +249,6 @@ export default memo(function Filters({
isLoggedIn,
onLoginRequired,
isLicensed,
isAdmin,
onUpgradeClick,
onResetTutorial,
filterImpacts,
@ -250,119 +257,14 @@ export default memo(function Filters({
savingSearch,
}: FiltersProps) {
const { t } = useTranslation();
const modeRestrictions = useMemo(() => {
const map: Record<string, Set<ListingType>> = {};
for (const f of features) {
if (f.modes && f.modes.length > 0) {
map[f.name] = new Set(f.modes as ListingType[]);
}
}
return map;
}, [features]);
const linkedFeatures = useMemo(() => {
const pairs: [string, string][] = [];
const seen = new Set<string>();
for (const f of features) {
if (f.linked && !seen.has(f.name)) {
pairs.push([f.name, f.linked]);
seen.add(f.linked);
}
}
return pairs;
}, [features]);
const isAllowed = useCallback(
(name: string, mode: ListingType) => {
const allowed = modeRestrictions[name];
return !allowed || allowed.has(mode);
},
[modeRestrictions]
);
const activeListingType = useMemo((): ListingType => {
const val = filters['Listing status'] as string[] | undefined;
if (!val || val.length === 0) return 'historical';
if (val.includes('For sale')) return 'buy';
if (val.includes('For rent')) return 'rent';
return 'historical';
}, [filters]);
const availableFeatures = useMemo(
() =>
features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
[features, enabledFeatures, activeListingType, isAllowed]
);
const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
() => features.filter((f) => !enabledFeatures.has(f.name)),
[features, enabledFeatures]
);
const parkedFiltersRef = useRef<FeatureFilters>({});
const handleListingSelect = useCallback(
(type: ListingType) => {
// Track what will be active after swaps (to avoid conflicts with restoration)
const activeAfterSwaps = new Set<string>();
for (const name of Object.keys(filters)) {
if (name === 'Listing status') continue;
if (isAllowed(name, type)) {
activeAfterSwaps.add(name);
continue;
}
// Check if this feature has a linked counterpart in the new mode
let swapped = false;
for (const [a, b] of linkedFeatures) {
const counterpart = name === a ? b : name === b ? a : null;
if (counterpart && isAllowed(counterpart, type)) {
onFilterChange(counterpart, filters[name] as [number, number]);
onRemoveFilter(name);
activeAfterSwaps.add(counterpart);
swapped = true;
break;
}
}
if (!swapped) {
parkedFiltersRef.current[name] = filters[name];
onRemoveFilter(name);
}
}
// Restore parked filters that are now allowed in the new mode
const restored: string[] = [];
for (const [name, value] of Object.entries(parkedFiltersRef.current)) {
if (isAllowed(name, type) && !activeAfterSwaps.has(name)) {
onFilterChange(name, value);
activeAfterSwaps.add(name);
restored.push(name);
} else if (!isAllowed(name, type)) {
// Try restoring as linked counterpart
for (const [a, b] of linkedFeatures) {
const counterpart = name === a ? b : name === b ? a : null;
if (counterpart && isAllowed(counterpart, type) && !activeAfterSwaps.has(counterpart)) {
onFilterChange(counterpart, value);
activeAfterSwaps.add(counterpart);
restored.push(name);
break;
}
}
}
}
for (const name of restored) {
delete parkedFiltersRef.current[name];
}
const valueMap: Record<string, string> = {
historical: 'Historical sale',
buy: 'For sale',
rent: 'For rent',
};
onFilterChange('Listing status', [valueMap[type]]);
},
[filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures]
const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name)),
[features, enabledFeatures]
);
const containerRef = useRef<HTMLDivElement>(null);
@ -472,10 +374,13 @@ export default memo(function Filters({
className="relative flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full touch-pan-y"
>
<div
className="flex flex-col min-h-0"
style={{
flex: activeFilterCollapsed ? '0 0 auto' : addFilterCollapsed ? '1 1 0' : '3 1 0',
}}
className={`flex flex-col md:min-h-0 ${
activeFilterCollapsed
? 'md:[flex:0_0_auto]'
: addFilterCollapsed
? 'md:[flex:1_1_0]'
: 'md:[flex:3_1_0]'
}`}
>
<button
onClick={() => setActiveFilterCollapsed((v) => !v)}
@ -518,291 +423,302 @@ export default memo(function Filters({
</div>
</button>
{!activeFilterCollapsed && <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<AiFilterInput
loading={aiFilterLoading}
error={aiFilterError}
errorType={aiFilterErrorType}
notes={aiFilterNotes}
summary={aiFilterSummary}
onSubmit={onAiFilterSubmit}
isLoggedIn={isLoggedIn}
onLoginRequired={onLoginRequired}
/>
<div className="px-3 pb-2 space-y-2">
{isAdmin && (
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
{(['historical', 'buy', 'rent'] as const).map((type) => {
const labels = { historical: t('filters.historical'), buy: t('filters.buy'), rent: t('filters.rent') };
const isActive = activeListingType === type;
return (
<button
key={type}
onClick={() => handleListingSelect(type)}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
isActive
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
>
{labels[type]}
</button>
);
})}
</div>
{!activeFilterCollapsed && (
<div
ref={scrollRef}
className="md:flex-1 md:min-h-0 md:overflow-y-auto overflow-x-hidden"
>
<AiFilterInput
loading={aiFilterLoading}
error={aiFilterError}
errorType={aiFilterErrorType}
notes={aiFilterNotes}
summary={aiFilterSummary}
onSubmit={onAiFilterSubmit}
isLoggedIn={isLoggedIn}
onLoginRequired={onLoginRequired}
/>
<div className="px-3 pb-2 space-y-2">
<button
onClick={() => setShowPhilosophy(true)}
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
>
<LightbulbIcon />
{t('filters.findingPerfectPostcode')}
</button>
</div>
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
{t('filters.addFiltersHint')}
</p>
)}
<button
onClick={() => setShowPhilosophy(true)}
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
>
<LightbulbIcon />
{t('filters.findingPerfectPostcode')}
</button>
</div>
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
{t('filters.addFiltersHint')}
</p>
)}
<div className="px-2 py-1 space-y-1">
{enabledFeatureList.map((feature, featureIdx) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
<div className="px-2 py-1 space-y-1">
{enabledFeatureList.map((feature, featureIdx) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
))}
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} size="sm" />
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
<PillGroup>
{allValues.map((val) => (
<PillToggle
key={val}
label={ts(val)}
active={selectedValues.includes(val)}
onClick={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
size="xs"
/>
))}
</PillGroup>
{filterImpacts?.[feature.name] != null &&
filterImpacts[feature.name] > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
+{formatNumber(filterImpacts[feature.name])} without this filter
</p>
)}
</div>
</Fragment>
);
}
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const hist = feature.histogram;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [
hist?.min ?? feature.min!,
hist?.max ?? feature.max!,
];
const scale = percentileScales.get(feature.name);
const dataMin = hist?.min ?? feature.min!;
const dataMax = hist?.max ?? feature.max!;
const clampMin = displayValue[0] <= dataMin;
const clampMax = displayValue[1] >= dataMax;
const isAtMin = displayValue[0] === dataMin;
const isAtMax = displayValue[1] === dataMax;
const sliderValue: [number, number] = scale
? [
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
clampMin ? feature.min! : displayValue[0],
clampMax ? feature.max! : displayValue[1],
];
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon =
getFeatureIcon(feature.name, mobileIconClass) ||
(() => {
const G = feature.group ? getGroupIcon(feature.group) : null;
return G ? <G className={mobileIconClass} /> : null;
})();
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx && travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
))}
{featureIdx === travelInsertIdx &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
))}
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} size="sm" />
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel
feature={feature}
size="sm"
className="min-w-0 shrink"
hideIconOnMobile
/>
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
<PillGroup>
{allValues.map((val) => (
<PillToggle
key={val}
label={ts(val)}
active={selectedValues.includes(val)}
onClick={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
size="xs"
<div className="flex md:block items-start gap-1.5">
{mobileIcon && (
<div className="md:hidden shrink-0 pt-0.5">{mobileIcon}</div>
)}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = feature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0
? (hist?.min ?? feature.min!)
: snap(scale.toValue(pMin)),
pMax >= 100
? (hist?.max ?? feature.max!)
: snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
))}
</PillGroup>
{filterImpacts?.[feature.name] != null && filterImpacts[feature.name] > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
+{formatNumber(filterImpacts[feature.name])} without this filter
</p>
)}
<SliderLabels
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={feature.raw}
feature={feature}
onValueChange={(v) => onFilterChange(feature.name, v)}
/>
{filterImpacts?.[feature.name] != null &&
filterImpacts[feature.name] > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpacts[feature.name])} without this filter
</p>
)}
</div>
</div>
</div>
</Fragment>
);
}
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const hist = feature.histogram;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [
hist?.min ?? feature.min!,
hist?.max ?? feature.max!,
];
const scale = percentileScales.get(feature.name);
const dataMin = hist?.min ?? feature.min!;
const dataMax = hist?.max ?? feature.max!;
const clampMin = displayValue[0] <= dataMin;
const clampMax = displayValue[1] >= dataMax;
const isAtMin = displayValue[0] === dataMin;
const isAtMax = displayValue[1] === dataMax;
const sliderValue: [number, number] = scale
? [
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
clampMin ? feature.min! : displayValue[0],
clampMax ? feature.max! : displayValue[1],
];
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon = getFeatureIcon(feature.name, mobileIconClass) || (() => {
const G = feature.group ? getGroupIcon(feature.group) : null;
return G ? <G className={mobileIconClass} /> : null;
})();
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx && travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
))}
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile />
<FeatureActions
feature={feature}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
<div className="flex md:block items-start gap-1.5">
{mobileIcon && <div className="md:hidden shrink-0 pt-0.5">{mobileIcon}</div>}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = feature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
pMax >= 100
? (hist?.max ?? feature.max!)
: snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={feature.raw}
feature={feature}
onValueChange={(v) => onFilterChange(feature.name, v)}
/>
{filterImpacts?.[feature.name] != null && filterImpacts[feature.name] > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpacts[feature.name])} without this filter
</p>
)}
</div>
</div>
})}
{travelInsertIdx >= enabledFeatureList.length &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
</Fragment>
);
})}
{travelInsertIdx >= enabledFeatureList.length && travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/>
</div>
))}
))}
</div>
</div>
</div>}
)}
</div>
<div
className="flex flex-col min-h-0 border-t border-warm-200 dark:border-warm-700"
style={{
flex: addFilterCollapsed ? '0 0 auto' : activeFilterCollapsed ? '1 1 0' : '2 1 0',
}}
className={`flex flex-col md:min-h-0 border-t border-warm-200 dark:border-warm-700 ${
addFilterCollapsed
? 'md:[flex:0_0_auto]'
: activeFilterCollapsed
? 'md:[flex:1_1_0]'
: 'md:[flex:2_1_0]'
}`}
>
<button
onClick={() => setAddFilterCollapsed((v) => !v)}
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
>
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">{t('filters.addFilter')}</span>
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
{t('filters.addFilter')}
</span>
<ChevronIcon
direction={addFilterCollapsed ? 'down' : 'up'}
className="w-4 h-4 text-warm-400 dark:text-warm-500"
/>
</button>
{!addFilterCollapsed && (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={features}
@ -850,11 +766,12 @@ export default memo(function Filters({
</div>
{showPhilosophy && (
<InfoPopup title={t('filters.findingPerfectPostcode')} onClose={() => setShowPhilosophy(false)}>
<InfoPopup
title={t('filters.findingPerfectPostcode')}
onClose={() => setShowPhilosophy(false)}
>
<div className="space-y-4 text-sm">
<p className="text-warm-600 dark:text-warm-300">
{t('philosophy.intro')}
</p>
<p className="text-warm-600 dark:text-warm-300">{t('philosophy.intro')}</p>
<div className="space-y-2">
{([1, 2, 3, 4, 5, 6] as const).map((n) => (
@ -872,9 +789,7 @@ export default memo(function Filters({
))}
</div>
<p className="text-warm-500 dark:text-warm-400 italic text-xs">
{t('philosophy.tip')}
</p>
<p className="text-warm-500 dark:text-warm-400 italic text-xs">{t('philosophy.tip')}</p>
{onResetTutorial && (
<button
@ -900,7 +815,10 @@ export default memo(function Filters({
)}
{showClearPopup && (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setShowClearPopup(false)}>
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={() => setShowClearPopup(false)}
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"

View file

@ -8,19 +8,27 @@ export default function HistogramLegend() {
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.tealBars')}</span> {t('histogramLegend.tealBarsDesc')}
<span className="font-medium text-warm-900 dark:text-warm-100">
{t('histogramLegend.tealBars')}
</span>{' '}
{t('histogramLegend.tealBarsDesc')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.greyBars')}</span> {t('histogramLegend.greyBarsDesc')}
<span className="font-medium text-warm-900 dark:text-warm-100">
{t('histogramLegend.greyBars')}
</span>{' '}
{t('histogramLegend.greyBarsDesc')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.dashedLine')}</span>{' '}
<span className="font-medium text-warm-900 dark:text-warm-100">
{t('histogramLegend.dashedLine')}
</span>{' '}
{t('histogramLegend.dashedLineDesc')}
</span>
</div>

View file

@ -39,8 +39,8 @@ export default memo(function HoverCard({
const results: { name: string; value: string }[] = [];
// Show stats for active filters (up to 4), excluding Listing status
for (const name of activeFilterNames.filter((n) => n !== 'Listing status').slice(0, 4)) {
// Show stats for active filters (up to 4)
for (const name of activeFilterNames.slice(0, 4)) {
const val = data[`avg_${name}`] ?? data[`min_${name}`];
if (val == null || typeof val !== 'number') continue;
const meta = featureMap.get(name);
@ -95,7 +95,8 @@ export default memo(function HoverCard({
{/* Property count */}
{count != null && (
<div className="text-xs text-warm-500 dark:text-warm-300 mb-2">
{count.toLocaleString()} {count === 1 ? t('common.property') : t('common.propertiesPlural')}
{count.toLocaleString()}{' '}
{count === 1 ? t('common.property') : t('common.propertiesPlural')}
</div>
)}

View file

@ -125,7 +125,8 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
<BicycleIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
)}
<span className="text-[11px] text-warm-500 dark:text-warm-400">
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes} {t('common.min')}
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes}{' '}
{t('common.min')}
</span>
</div>
</div>
@ -145,7 +146,9 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
<div className="pb-1.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<RouteBadge mode={leg.mode} />
<span className="text-[11px] text-warm-500 dark:text-warm-400">{leg.minutes} {t('common.min')}</span>
<span className="text-[11px] text-warm-500 dark:text-warm-400">
{leg.minutes} {t('common.min')}
</span>
</div>
{leg.from && leg.to && (
<div className="text-[11px] text-warm-600 dark:text-warm-300 mt-0.5">
@ -231,7 +234,9 @@ export default function JourneyInstructions({
return (
<div className="mx-3 mt-2 space-y-2">
{label && (
<div className="text-xs text-warm-500 dark:text-warm-400">{t('areaPane.journeysFrom', { label })}</div>
<div className="text-xs text-warm-500 dark:text-warm-400">
{t('areaPane.journeysFrom', { label })}
</div>
)}
{journeys.map((j) => {
const displayLegs = j.legs ? invertLegs(j.legs) : null;
@ -253,7 +258,9 @@ export default function JourneyInstructions({
{j.loading ? (
<div className="flex items-center gap-2 py-1">
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
<span className="text-xs text-warm-500 dark:text-warm-400">{t('common.loading')}</span>
<span className="text-xs text-warm-500 dark:text-warm-400">
{t('common.loading')}
</span>
</div>
) : displayLegs && displayLegs.length > 0 ? (
<div>

View file

@ -11,6 +11,8 @@ import { SearchIcon } from '../ui/icons/SearchIcon';
export interface SearchedLocation {
postcode: string;
geometry: PostcodeGeometry;
latitude: number;
longitude: number;
}
const ZOOM_FOR_TYPE: Record<string, number> = {
@ -94,7 +96,12 @@ export default function LocationSearch({
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry });
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
});
search.clear();
if (isMobile) setExpanded(false);
} catch {
@ -139,7 +146,12 @@ export default function LocationSearch({
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry });
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
});
search.clear();
if (isMobile) setExpanded(false);
} catch {

View file

@ -136,15 +136,26 @@ export default memo(function Map({
const container = containerRef.current;
if (!container) return;
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
let initialized = false;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width > 0 && height > 0) {
setDimensions({ width, height });
if (!initialized) {
initialized = true;
setDimensions({ width, height });
} else {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => setDimensions({ width, height }), 150);
}
}
});
observer.observe(container);
return () => observer.disconnect();
return () => {
observer.disconnect();
if (resizeTimer) clearTimeout(resizeTimer);
};
}, []);
useEffect(() => {
@ -163,7 +174,22 @@ export default memo(function Map({
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
setInternalViewState(evt.viewState);
setInternalViewState((prev) => {
const next = evt.viewState;
// Skip re-render when viewport values haven't changed (e.g. container resize
// fires move events with identical lat/lng/zoom). Returning the same reference
// tells React to bail out.
if (
prev.latitude === next.latitude &&
prev.longitude === next.longitude &&
prev.zoom === next.zoom &&
prev.pitch === next.pitch &&
prev.bearing === next.bearing
) {
return prev;
}
return next;
});
}, []);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
@ -209,7 +235,13 @@ export default memo(function Map({
{...viewState}
onMove={handleMove}
onLoad={undefined}
onIdle={screenshotMode ? () => { window.__map_idle = true; } : undefined}
onIdle={
screenshotMode
? () => {
window.__map_idle = true;
}
: undefined
}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
@ -222,9 +254,7 @@ export default memo(function Map({
maxBounds={MAP_BOUNDS}
>
<DeckOverlay layers={layers} getTooltip={null} />
{!screenshotMode && (
<ScaleControl position="bottom-left" maxWidth={100} unit="metric" />
)}
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
</MapGL>
{screenshotMode ? (
ogMode ? (
@ -233,7 +263,10 @@ export default memo(function Map({
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-8 bg-navy-900/90 rounded-3xl px-14 py-10">
<LogoIcon className="w-24 h-24 text-teal-400" />
<span className="font-bold text-white whitespace-nowrap" style={{ fontSize: '5rem' }}>
<span
className="font-bold text-white whitespace-nowrap"
style={{ fontSize: '5rem' }}
>
Your perfect postcode
</span>
</div>
@ -280,7 +313,11 @@ export default memo(function Map({
(viewFeature && colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', { mode: modes.label(viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit') })}
featureLabel={t('travel.travelTime', {
mode: modes.label(
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
@ -302,6 +339,7 @@ export default memo(function Map({
enumValues={
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
}
featureName={colorFeatureMeta.name}
theme={theme}
raw={colorFeatureMeta.raw}
/>
@ -315,7 +353,8 @@ export default memo(function Map({
: [countRange.min, countRange.max]
}
totalCount={
totalCountProp ?? (usePostcodeView ? postcodeCountRange.total : countRange.total)
totalCountProp ??
(usePostcodeView ? postcodeCountRange.total : countRange.total)
}
showCancel={false}
onCancel={onCancelPin}

View file

@ -5,17 +5,23 @@ import {
FEATURE_GRADIENT,
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
ENUM_PALETTE,
getEnumPaletteForFeature,
} from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TickerValue } from '../ui/TickerValue';
function EnumSwatches({ values }: { values: string[] }) {
function EnumSwatches({
values,
palette,
}: {
values: string[];
palette: [number, number, number][];
}) {
return (
<div className="flex flex-col gap-1">
{values.map((label, i) => {
const color = ENUM_PALETTE[i % ENUM_PALETTE.length];
const color = palette[i % palette.length];
return (
<div key={label} className="flex items-center gap-1.5">
<div
@ -30,11 +36,17 @@ function EnumSwatches({ values }: { values: string[] }) {
);
}
function InlineEnumSwatches({ values }: { values: string[] }) {
function InlineEnumSwatches({
values,
palette,
}: {
values: string[];
palette: [number, number, number][];
}) {
return (
<div className="flex items-center gap-2 flex-1 min-w-[40%] flex-wrap">
{values.map((label, i) => {
const color = ENUM_PALETTE[i % ENUM_PALETTE.length];
const color = palette[i % palette.length];
return (
<div key={label} className="flex items-center gap-1">
<div
@ -58,6 +70,7 @@ export default function MapLegend({
onCancel,
mode,
enumValues,
featureName,
theme = 'light',
inline = false,
suffix,
@ -70,6 +83,7 @@ export default function MapLegend({
onCancel: () => void;
mode: 'feature' | 'density';
enumValues?: string[];
featureName?: string;
theme?: 'light' | 'dark';
inline?: boolean;
suffix?: string;
@ -78,6 +92,7 @@ export default function MapLegend({
}) {
const { t } = useTranslation();
const isEnum = enumValues && enumValues.length > 0;
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues);
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle =
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
@ -114,7 +129,7 @@ export default function MapLegend({
</button>
)}
{isEnum ? (
<InlineEnumSwatches values={enumValues} />
<InlineEnumSwatches values={enumValues} palette={enumPalette} />
) : (
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
{rangeMin}
@ -144,7 +159,7 @@ export default function MapLegend({
)}
</div>
{isEnum ? (
<EnumSwatches values={enumValues} />
<EnumSwatches values={enumValues} palette={enumPalette} />
) : (
<>
<div className="h-3 rounded" style={{ background: gradientStyle }} />

View file

@ -37,6 +37,7 @@ import {
} from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { ts } from '../../i18n/server';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
@ -77,6 +78,8 @@ interface MapPageProps {
isPropertySaved?: (address?: string, postcode?: string) => boolean;
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
deferTutorial?: boolean;
onSaveSearch?: (name: string) => Promise<void>;
savingSearch?: boolean;
}
export default function MapPage({
@ -105,12 +108,20 @@ export default function MapPage({
isPropertySaved,
getSavedPropertyId,
deferTutorial = false,
onSaveSearch,
savingSearch,
}: MapPageProps) {
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [, mobileResizeHandlers, mobileMapRef] = usePaneResize(
Math.round(window.innerHeight * 0.4),
120,
0.8,
'top'
);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
@ -150,31 +161,48 @@ export default function MapPage({
handleDragEnd,
handleDragEndNoCommit,
handleTogglePin,
handleSetPin,
handleCancelPin,
} = useFilters({
initialFilters,
features,
});
const aiFilters = useAiFilters();
const {
fetchAiFilters,
loading: aiFilterLoading,
error: aiFilterError,
errorType: aiFilterErrorType,
notes: aiFilterNotes,
summary: aiFilterSummary,
} = useAiFilters();
const travelTime = useTravelTime(initialTravelTime);
const {
entries,
activeEntries,
handleAddEntry,
handleRemoveEntry,
handleSetDestination,
handleSetEntries,
handleTimeRangeChange,
handleToggleBest,
} = useTravelTime(initialTravelTime);
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
const mapData = useMapData({
filters,
features,
viewFeature,
activeFeature,
travelTimeEntries: entries,
});
const handleAiFilterSubmit = useCallback(
async (query: string) => {
// Derive current listing type from Listing status filter
const listingVal = filters['Listing status'] as string[] | undefined;
const listingType = listingVal?.includes('For sale')
? 'buy'
: listingVal?.includes('For rent')
? 'rent'
: 'historical';
// Build context from current filters for conversational refinement
const context = {
filters,
travelTime: travelTime.activeEntries.map((entry) => ({
travelTime: activeEntries.map((entry) => ({
mode: entry.mode,
label: entry.label,
min: entry.timeRange?.[0],
@ -183,11 +211,7 @@ export default function MapPage({
};
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
const result = await aiFilters.fetchAiFilters(
query,
hasContext ? context : undefined,
listingType
);
const result = await fetchAiFilters(query, hasContext ? context : undefined);
if (!result) return;
handleSetFilters(result.filters);
// Always sync travel time entries — clear stale ones when AI returns none
@ -198,67 +222,100 @@ export default function MapPage({
timeRange: [tt.min ?? 0, tt.max ?? 120] as [number, number],
useBest: false,
}));
travelTime.handleSetEntries(newEntries);
handleSetEntries(newEntries);
// Pan to the first travel time destination (mirroring handleTravelTimeSetDestination)
const firstTT = result.travelTimeFilters[0];
if (firstTT?.slug) {
try {
const res = await fetch(
apiUrl('travel-destinations', new URLSearchParams({ mode: firstTT.mode })),
authHeaders({})
);
if (res.ok) {
const data: { destinations: { slug: string; lat: number; lon: number }[] } =
await res.json();
const dest = data.destinations.find((d) => d.slug === firstTT.slug);
if (dest) {
mapFlyToRef.current?.(
dest.lat,
dest.lon,
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom
);
}
}
} catch {
// Non-critical — filters are already applied, just skip the pan
}
}
},
[
aiFilters.fetchAiFilters,
handleSetFilters,
travelTime.handleSetEntries,
travelTime.activeEntries,
filters,
]
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters, mapData.currentView?.zoom]
);
const handleClearAll = useCallback(() => {
handleSetFilters({});
handleCancelPin();
handleSetEntries([]);
}, [handleSetFilters, handleCancelPin, handleSetEntries]);
const handleTravelTimeRemoveEntry = useCallback(
(index: number) => {
const entry = travelTime.entries[index];
const entry = entries[index];
if (entry?.slug && pinnedFeature === travelFieldKey(entry)) {
handleCancelPin();
}
travelTime.handleRemoveEntry(index);
handleRemoveEntry(index);
},
[travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin]
[handleRemoveEntry, entries, pinnedFeature, handleCancelPin]
);
const handleTravelTimeDragEnd = useCallback(
(index: number) => {
const dv = handleDragEndNoCommit();
if (dv) travelTime.handleTimeRangeChange(index, dv);
if (dv) handleTimeRangeChange(index, dv);
},
[handleDragEndNoCommit, travelTime.handleTimeRangeChange]
[handleDragEndNoCommit, handleTimeRangeChange]
);
const license = useLicense();
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
const mapData = useMapData({
filters,
features,
viewFeature,
activeFeature,
travelTimeEntries: travelTime.entries,
});
const filterCounts = useFilterCounts(filters, features, mapData.bounds);
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries);
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string, lat: number, lon: number) => {
travelTime.handleSetDestination(index, slug, label);
handleSetDestination(index, slug, label);
if (slug) {
mapFlyToRef.current?.(lat, lon, mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom);
}
},
[travelTime.handleSetDestination, mapData.currentView?.zoom]
[handleSetDestination, mapData.currentView?.zoom]
);
// First transit destination — used to pick the best central_postcode for journey display
const journeyDest = useMemo(() => {
const entry = travelTime.entries.find((e) => e.mode === 'transit' && e.slug);
const entry = entries.find((e) => e.mode === 'transit' && e.slug);
return entry ? { mode: entry.mode, slug: entry.slug } : null;
}, [travelTime.entries]);
}, [entries]);
const selection = useHexagonSelection({
const {
selectedHexagon,
properties,
propertiesTotal,
loadingProperties,
areaStats,
loadingAreaStats,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
handleHexagonClick,
handleHexagonHover,
handleViewPropertiesFromArea,
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
selectedPostcodeGeometry,
handleLocationSearch,
} = useHexagonSelection({
filters,
features,
resolution: mapData.resolution,
@ -268,13 +325,13 @@ export default function MapPage({
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
selection.handleLocationSearch(result.postcode, result.geometry);
handleLocationSearch(result.postcode, result.geometry, result.latitude, result.longitude);
if (isMobile) setMobileDrawerOpen(true);
} else {
selection.handleCloseSelection();
handleCloseSelection();
}
},
[selection.handleLocationSearch, selection.handleCloseSelection, isMobile]
[handleLocationSearch, handleCloseSelection, isMobile]
);
const handleZoomToFreeZone = useCallback(() => {
@ -288,18 +345,11 @@ export default function MapPage({
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
mapData.currentView,
filters,
features,
selectedPOICategories,
selection.rightPaneTab,
travelTime.entries
);
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, rightPaneTab, entries);
useEffect(() => {
mapData.setInitialView(initialViewState);
selection.setRightPaneTab(initialTab);
setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Navigate to a specific postcode on mount (e.g. from saved properties)
@ -325,7 +375,7 @@ export default function MapPage({
geometry: PostcodeGeometry;
}) => {
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
selection.handleLocationSearch(data.postcode, data.geometry);
handleLocationSearch(data.postcode, data.geometry, data.latitude, data.longitude);
if (isMobile) setMobileDrawerOpen(true);
}
)
@ -357,7 +407,6 @@ export default function MapPage({
return () => window.removeEventListener('popstate', handlePopState);
}, [isMobile]);
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
handleHexagonClick(id, isPostcode, geometry);
@ -369,8 +418,8 @@ export default function MapPage({
);
const hexagonLocation = useMemo(() => {
const hexId = selection.selectedHexagon?.id;
const isPostcode = selection.selectedHexagon?.type === 'postcode';
const hexId = selectedHexagon?.id;
const isPostcode = selectedHexagon?.type === 'postcode';
if (isPostcode) {
// For postcodes, get centroid from postcodeData; postcode string is the selection id
@ -386,16 +435,16 @@ export default function MapPage({
lat: hex.lat as number,
lon: hex.lon as number,
resolution: mapData.resolution,
postcode: selection.areaStats?.central_postcode,
postcode: areaStats?.central_postcode,
};
}
}, [
selection.selectedHexagon?.id,
selection.selectedHexagon?.type,
selectedHexagon?.id,
selectedHexagon?.type,
mapData.data,
mapData.postcodeData,
mapData.resolution,
selection.areaStats?.central_postcode,
areaStats?.central_postcode,
]);
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
@ -437,12 +486,7 @@ export default function MapPage({
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
}, [mapData.licenseRequired]);
const densityLabel = useMemo(() => {
const listingVal = filters['Listing status'] as string[] | undefined;
if (listingVal?.includes('For sale')) return 'Properties for sale';
if (listingVal?.includes('For rent')) return 'Properties for rent';
return 'Historical property matches';
}, [filters]);
const densityLabel = t('mapLegend.historicalMatches');
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -544,7 +588,7 @@ export default function MapPage({
screenshotMode
ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
travelTimeEntries={entries}
/>
</div>
);
@ -552,22 +596,20 @@ export default function MapPage({
const renderAreaPane = () => (
<AreaPane
stats={selection.areaStats}
stats={areaStats}
globalFeatures={features}
loading={selection.loadingAreaStats}
hexagonId={selection.selectedHexagon?.id || null}
isPostcode={selection.selectedHexagon?.type === 'postcode'}
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
postcodeData={
selection.selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find(
(f) => f.properties.postcode === selection.selectedHexagon?.id
) || null
selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find((f) => f.properties.postcode === selectedHexagon?.id) || null
: null
}
onViewProperties={selection.handleViewPropertiesFromArea}
onViewProperties={handleViewPropertiesFromArea}
hexagonLocation={hexagonLocation}
filters={filters}
travelTimeEntries={travelTime.activeEntries}
travelTimeEntries={activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
/>
@ -575,11 +617,11 @@ export default function MapPage({
const renderPropertiesPane = () => (
<PropertiesPane
properties={selection.properties}
total={selection.propertiesTotal}
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
@ -614,26 +656,28 @@ export default function MapPage({
onTogglePin={handleTogglePin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries}
onTravelTimeAddEntry={travelTime.handleAddEntry}
travelTimeEntries={entries}
onTravelTimeAddEntry={handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
onTravelTimeRangeChange={handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={travelTime.handleToggleBest}
aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error}
aiFilterErrorType={aiFilters.errorType}
aiFilterNotes={aiFilters.notes}
aiFilterSummary={aiFilters.summary}
onTravelTimeToggleBest={handleToggleBest}
aiFilterLoading={aiFilterLoading}
aiFilterError={aiFilterError}
aiFilterErrorType={aiFilterErrorType}
aiFilterNotes={aiFilterNotes}
aiFilterSummary={aiFilterSummary}
onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user}
onLoginRequired={onRegisterClick ?? (() => {})}
isLicensed={user?.subscription === 'licensed'}
isAdmin={user?.isAdmin === true}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
filterImpacts={filterCounts.impacts}
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
/>
);
@ -651,7 +695,10 @@ export default function MapPage({
</div>
)}
<div className="relative overflow-hidden" style={{ flex: '45 0 0' }}>
<div
ref={mobileMapRef}
className="relative overflow-hidden"
>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
@ -664,19 +711,19 @@ export default function MapPage({
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selection.selectedHexagon?.id || null}
hoveredHexagonId={selection.hoveredHexagon}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={selection.handleHexagonHover}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={travelTime.entries}
travelTimeEntries={entries}
/>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
@ -702,13 +749,27 @@ export default function MapPage({
</div>
<div
className="bg-white dark:bg-warm-900 border-t border-warm-200 dark:border-warm-700 overflow-hidden flex flex-col"
style={{ flex: '55 0 0' }}
className="relative z-10 py-2 -my-2 cursor-row-resize touch-none group"
{...mobileResizeHandlers}
>
<div className="h-3 flex items-center justify-center bg-warm-100 dark:bg-navy-800 group-hover:bg-warm-200 dark:group-hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700">
<div className="flex flex-row gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
</div>
</div>
</div>
<div className="flex-1 min-h-0 bg-white dark:bg-warm-900 overflow-hidden flex flex-col">
{viewFeature && mapData.colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', { mode: modes.label(viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit') })}
featureLabel={t('travel.travelTime', {
mode: modes.label(
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
@ -721,14 +782,15 @@ export default function MapPage({
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
: mobileLegendMeta.name
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
: ts(mobileLegendMeta.name)
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
featureName={mobileLegendMeta.name}
theme={theme}
inline
raw={mobileLegendMeta.raw}
@ -748,17 +810,17 @@ export default function MapPage({
<div className="flex-1 min-h-0">{renderFilters()}</div>
</div>
{mobileDrawerOpen && selection.selectedHexagon && (
{mobileDrawerOpen && selectedHexagon && (
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
tab={selection.rightPaneTab}
tab={rightPaneTab}
onTabChange={(t) => {
if (t === 'properties') {
selection.handlePropertiesTabClick();
handlePropertiesTabClick();
} else {
selection.setRightPaneTab(t);
setRightPaneTab(t);
}
}}
/>
@ -835,19 +897,20 @@ export default function MapPage({
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selection.selectedHexagon?.id || null}
hoveredHexagonId={selection.hoveredHexagon}
onHexagonClick={selection.handleHexagonClick}
onHexagonHover={selection.handleHexagonHover}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={filterCounts.total || undefined}
/>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
@ -876,7 +939,7 @@ export default function MapPage({
)}
</div>
{selection.selectedHexagon && (
{selectedHexagon && (
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
@ -896,16 +959,16 @@ export default function MapPage({
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
isActive={rightPaneTab === 'area'}
onClick={() => setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
isActive={rightPaneTab === 'properties'}
onClick={handlePropertiesTabClick}
/>
<button
onClick={selection.handleCloseSelection}
onClick={handleCloseSelection}
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close pane"
>
@ -914,7 +977,7 @@ export default function MapPage({
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
{rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
</div>
</div>
</div>

View file

@ -37,7 +37,11 @@ export default function MobileDrawer({
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
{/* Tab bar + close */}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
<TabButton label={t('common.area')} isActive={tab === 'area'} onClick={() => onTabChange('area')} />
<TabButton
label={t('common.area')}
isActive={tab === 'area'}
onClick={() => onTabChange('area')}
/>
<TabButton
label={t('common.properties')}
isActive={tab === 'properties'}

View file

@ -180,11 +180,6 @@ function PropertyCard({
const rooms = getNum(property, 'Number of bedrooms & living rooms');
const age = getNum(property, 'Construction year');
const transactionDate = getNum(property, 'Date of last transaction');
const askingPrice = getNum(property, 'Asking price');
const askingRent = getNum(property, 'Asking rent (monthly)');
const bedrooms = getNum(property, 'Bedrooms');
const bathrooms = getNum(property, 'Bathrooms');
const listingDate = getNum(property, 'Listing date');
return (
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
@ -193,7 +188,14 @@ function PropertyCard({
<div className="font-semibold dark:text-warm-100">
{property.address || t('propertyCard.unknownAddress')}
</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
<div className="text-sm text-warm-600 dark:text-warm-400 flex items-center gap-1.5">
{property.postcode}
{property.former_council_house === 'Yes' && (
<span className="text-xs bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400 rounded-full px-1.5 py-0.5 font-medium leading-none">
{t('propertyCard.exCouncilBadge')}
</span>
)}
</div>
</div>
{onSave && (
<button
@ -216,49 +218,20 @@ function PropertyCard({
</div>
)}
{askingPrice !== undefined && (
{price !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
{property.price_qualifier && (
<span className="text-sm font-normal text-warm-500 dark:text-warm-400">
{property.price_qualifier}{' '}
£{formatNumber(price)}
{transactionDate !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
({formatTransactionDate(transactionDate)})
</span>
)}
£{formatNumber(askingPrice)}
</div>
)}
{askingRent !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
£{formatNumber(askingRent)}
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">{t('propertyCard.perMonth')}</span>
</div>
)}
{price !== undefined && (
<div
className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}
>
{askingPrice !== undefined || askingRent !== undefined ? (
{pricePerSqm !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{t('propertyCard.lastSold', { price: formatNumber(price) })}
{transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`}
{' '}
£{formatNumber(pricePerSqm)}/m²
</span>
) : (
<>
£{formatNumber(price)}
{transactionDate !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
({formatTransactionDate(transactionDate)})
</span>
)}
{pricePerSqm !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
£{formatNumber(pricePerSqm)}/m²
</span>
)}
</>
)}
</div>
)}
@ -275,7 +248,8 @@ function PropertyCard({
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
{property.property_type && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.type')}</span> {ts(property.property_type)}
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.type')}</span>{' '}
{ts(property.property_type)}
</div>
)}
{property.built_form && (
@ -296,21 +270,10 @@ function PropertyCard({
{formatNumber(floorArea)}m²
</div>
)}
{bedrooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bedrooms')}</span>{' '}
{formatNumber(bedrooms)}
</div>
)}
{bathrooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bathrooms')}</span>{' '}
{formatNumber(bathrooms)}
</div>
)}
{rooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.rooms')}</span> {formatNumber(rooms)}
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.rooms')}</span>{' '}
{formatNumber(rooms)}
</div>
)}
{age !== undefined && (
@ -327,37 +290,19 @@ function PropertyCard({
)}
{property.potential_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.epcPotential')}</span>{' '}
<span className="text-warm-500 dark:text-warm-400">
{t('propertyCard.epcPotential')}
</span>{' '}
{ts(property.potential_energy_rating)}
</div>
)}
{listingDate !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.listed')}</span>{' '}
{formatTransactionDate(listingDate)}
</div>
)}
</div>
{property.listing_features && property.listing_features.length > 0 && (
<div className="mt-2">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">{t('propertyCard.keyFeatures')}</div>
<div className="flex flex-wrap gap-1">
{property.listing_features.map((feature, idx) => (
<span
key={idx}
className="text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
>
{feature}
</span>
))}
</div>
</div>
)}
{property.renovation_history && property.renovation_history.length > 0 && (
<div className="mt-2">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">{t('propertyCard.renovations')}</div>
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{t('propertyCard.renovations')}
</div>
<div className="flex flex-wrap gap-1">
{property.renovation_history.map((reno, idx) => (
<span
@ -372,18 +317,6 @@ function PropertyCard({
</div>
)}
{property.listing_url && (
<div className="mt-2">
<a
href={property.listing_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
{t('propertyCard.viewExternalListing')} &rarr;
</a>
</div>
)}
</div>
);
}

View file

@ -44,7 +44,7 @@ export function TravelTimeCard({
dragValue,
onTogglePin,
onSetDestination,
onTimeRangeChange,
onTimeRangeChange: _onTimeRangeChange,
onDragStart,
onDragChange,
onDragEnd,
@ -115,7 +115,12 @@ export function TravelTimeCard({
{/* Best-case toggle — transit only, shown when destination is set */}
{slug && mode === 'transit' && (
<div className="flex items-center gap-1.5">
<PillToggle label={t('travel.bestCase')} active={useBest} onClick={onToggleBest} size="xs" />
<PillToggle
label={t('travel.bestCase')}
active={useBest}
onClick={onToggleBest}
size="xs"
/>
<IconButton onClick={() => setShowBestInfo(true)} title={t('travel.bestCaseTitle')}>
<InfoIcon className="w-3 h-3" />
</IconButton>
@ -149,8 +154,12 @@ export function TravelTimeCard({
onPointerUp={() => onDragEnd()}
/>
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">{formatFilterValue(displayRange[0])} {t('common.min')}</span>
<span className="absolute right-0">{formatFilterValue(displayRange[1])} {t('common.min')}</span>
<span className="absolute left-0">
{formatFilterValue(displayRange[0])} {t('common.min')}
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} {t('common.min')}
</span>
</div>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">

View file

@ -183,18 +183,12 @@ export default function PricingPage({
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-6">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">{t('pricingPage.title')}</h1>
<p className="text-lg text-warm-300 max-w-lg mx-auto">
{t('pricingPage.subtitle')}
</p>
<p className="text-lg text-warm-300 max-w-lg mx-auto">{t('pricingPage.subtitle')}</p>
</div>
<div className="relative z-10 max-w-2xl mx-auto px-6 mb-12 text-center">
<p className="text-warm-400 text-sm leading-relaxed mb-2">
{t('pricingPage.costContext')}
</p>
<p className="text-warm-200 font-semibold">
{t('pricingPage.lessThanSurvey')}
</p>
<p className="text-warm-400 text-sm leading-relaxed mb-2">{t('pricingPage.costContext')}</p>
<p className="text-warm-200 font-semibold">{t('pricingPage.lessThanSurvey')}</p>
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-16">
@ -285,7 +279,9 @@ export default function PricingPage({
: 'text-navy-950 dark:text-warm-100'
}`}
>
{tier.price_pence === 0 ? t('upgrade.free') : formatPricePence(tier.price_pence)}
{tier.price_pence === 0
? t('upgrade.free')
: formatPricePence(tier.price_pence)}
</span>
{tier.price_pence > 0 && (
<span
@ -321,7 +317,14 @@ export default function PricingPage({
<div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
<ul className="space-y-3 mb-6 flex-1">
{[t('pricingPage.feat1'), t('pricingPage.feat2'), t('pricingPage.feat3'), t('pricingPage.feat4'), t('pricingPage.feat5'), t('pricingPage.feat6')].map((feat, idx) => (
{[
t('pricingPage.feat1'),
t('pricingPage.feat2'),
t('pricingPage.feat3'),
t('pricingPage.feat4'),
t('pricingPage.feat5'),
t('pricingPage.feat6'),
].map((feat, idx) => (
<li key={idx} className="flex items-start gap-2.5 text-sm">
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">{feat}</span>
@ -338,7 +341,9 @@ export default function PricingPage({
</p>
)}
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{isFree ? t('pricingPage.noCreditCard') : t('pricingPage.moneyBackGuarantee')}
{isFree
? t('pricingPage.noCreditCard')
: t('pricingPage.moneyBackGuarantee')}
</p>
</>
) : isFilled ? (
@ -357,9 +362,7 @@ export default function PricingPage({
</div>
</div>
) : (
<p className="text-center text-warm-400 py-16">
{t('pricingPage.failedToLoad')}
</p>
<p className="text-center text-warm-400 py-16">{t('pricingPage.failedToLoad')}</p>
)}
</div>
</div>

View file

@ -192,7 +192,11 @@ export default function AuthModal({
required
minLength={8}
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={view === 'register' ? t('auth.passwordPlaceholderRegister') : t('auth.passwordPlaceholderLogin')}
placeholder={
view === 'register'
? t('auth.passwordPlaceholderRegister')
: t('auth.passwordPlaceholderLogin')
}
/>
{view === 'login' && (
<button
@ -207,9 +211,7 @@ export default function AuthModal({
)}
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
{t('auth.resetSent')}
</p>
<p className="text-sm text-teal-700 dark:text-teal-400">{t('auth.resetSent')}</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}

View file

@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import type { FeatureMeta } from '../../types';
import { ts, tsDesc } from '../../i18n/server';
import { ts, tsDesc, tsDetail } from '../../i18n/server';
import InfoPopup from './InfoPopup';
interface FeatureInfoPopupProps {
@ -34,7 +34,7 @@ export function FeatureInfoPopup({ feature, onClose, onNavigateToSource }: Featu
)}
{feature.detail && (
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
{feature.detail}
{tsDetail(feature.name, feature.detail)}
</p>
)}
</InfoPopup>

View file

@ -28,15 +28,6 @@ export function FeatureLabel({
const iconClass = `${mobileHide}w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0`;
const featureIcon = getFeatureIcon(feature.name, iconClass);
const GroupIcon = !featureIcon && feature.group ? getGroupIcon(feature.group) : null;
const modeLabels: Record<string, string> = {
historical: t('filters.historical'),
buy: t('filters.buy'),
rent: t('filters.rent'),
};
const modeTag =
feature.modes && feature.modes.length > 0
? feature.modes.map((m) => modeLabels[m] || m).join(' \u00B7 ')
: null;
const translatedName = ts(feature.name);
const translatedDesc = description ? tsDesc(feature.name, description) : undefined;
@ -48,11 +39,6 @@ export function FeatureLabel({
>
{translatedName}
</span>
{modeTag && (
<span className="shrink-0 text-[10px] leading-none font-medium px-1.5 py-0.5 rounded-full bg-warm-100 dark:bg-warm-800 text-warm-500 dark:text-warm-400 border border-warm-200 dark:border-warm-700">
{modeTag}
</span>
)}
{feature.detail && onShowInfo && (
<button
onClick={() => onShowInfo(feature)}

View file

@ -7,7 +7,8 @@ export default function LanguageDropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language) ?? SUPPORTED_LANGUAGES[0];
const current =
SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language) ?? SUPPORTED_LANGUAGES[0];
useEffect(() => {
if (!open) return;
@ -32,7 +33,13 @@ export default function LanguageDropdown() {
aria-label="Language"
>
<span className="text-base leading-none">{current.flag}</span>
<svg className="w-3 h-3 text-warm-400" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<svg
className="w-3 h-3 text-warm-400"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 5l3 3 3-3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>

View file

@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next';
import type { Page } from './Header';
import { PAGE_PATHS } from './Header';
import type { AuthUser } from '../../hooks/useAuth';
import { SUPPORTED_LANGUAGES } from '../../i18n';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { CheckIcon } from './icons/CheckIcon';
@ -45,6 +47,8 @@ export default function MobileMenu({
onShare,
copied,
}: MobileMenuProps) {
const { t, i18n } = useTranslation();
const mobileNavItem = (page: Page, label: string) => (
<a
key={page}
@ -72,24 +76,24 @@ export default function MobileMenu({
{/* Menu panel */}
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-50 flex flex-col shadow-xl">
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
<span className="font-semibold">Menu</span>
<span className="font-semibold">{t('mobileMenu.menu')}</span>
<button
onClick={onClose}
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label="Close menu"
aria-label={t('header.closeMenu')}
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
{mobileNavItem('home', 'Home')}
{mobileNavItem('dashboard', 'Dashboard')}
{mobileNavItem('learn', 'Learn')}
{mobileNavItem('home', t('mobileMenu.home'))}
{mobileNavItem('dashboard', t('header.dashboard'))}
{mobileNavItem('learn', t('header.learn'))}
{user?.subscription !== 'licensed' &&
!user?.isAdmin &&
mobileNavItem('pricing', 'Pricing')}
{user && mobileNavItem('invites', 'Invite Friends')}
{user && mobileNavItem('account', 'Account')}
mobileNavItem('pricing', t('header.pricing'))}
{user && mobileNavItem('invites', t('header.inviteFriends'))}
{user && mobileNavItem('account', t('userMenu.account'))}
{/* Dashboard actions */}
{activePage === 'dashboard' && (
@ -102,7 +106,7 @@ export default function MobileMenu({
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded"
>
{copied ? <CheckIcon className="w-5 h-5" /> : <ClipboardIcon className="w-5 h-5" />}
{copied ? 'Copied!' : 'Share'}
{copied ? t('common.copied') : t('common.share')}
</button>
<button
onClick={() => {
@ -113,7 +117,7 @@ export default function MobileMenu({
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
>
<DownloadIcon className="w-5 h-5" />
{exporting ? 'Exporting...' : 'Export'}
{exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
{onSaveSearch && (
<button
@ -129,13 +133,13 @@ export default function MobileMenu({
) : (
<BookmarkIcon className="w-5 h-5" />
)}
Save
{t('common.save')}
</button>
)}
{user && mobileNavItem('saved', 'Saved')}
{user && mobileNavItem('saved', t('header.saved'))}
</div>
)}
{activePage !== 'dashboard' && user && mobileNavItem('saved', 'Saved')}
{activePage !== 'dashboard' && user && mobileNavItem('saved', t('header.saved'))}
</nav>
{/* Theme toggle + Auth section at bottom */}
@ -148,9 +152,30 @@ export default function MobileMenu({
className="w-full flex items-center gap-3 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded transition-colors"
>
{theme === 'light' ? <SunIcon className="w-5 h-5" /> : <MoonIcon className="w-5 h-5" />}
<span>Theme: {theme === 'light' ? 'Light' : 'Dark'}</span>
<span>{theme === 'light' ? t('userMenu.themeLight') : t('userMenu.themeDark')}</span>
</button>
{/* Language selector */}
<div className="flex gap-1 px-4">
{SUPPORTED_LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => {
i18n.changeLanguage(lang.code);
localStorage.setItem('language', lang.code);
}}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded text-sm ${
i18n.language === lang.code
? 'bg-navy-700 text-white font-medium'
: 'text-warm-400 hover:bg-navy-800 hover:text-white'
}`}
>
<span className="text-base leading-none">{lang.flag}</span>
<span className="hidden sm:inline">{lang.label}</span>
</button>
))}
</div>
{/* Auth buttons */}
<div>
{user ? (
@ -163,7 +188,7 @@ export default function MobileMenu({
}}
className="shrink-0 text-sm text-warm-400 hover:text-white"
>
Log out
{t('userMenu.logOut')}
</button>
</div>
) : (
@ -175,7 +200,7 @@ export default function MobileMenu({
}}
className="flex-1 px-3 py-2.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
>
Log in
{t('header.logIn')}
</button>
<button
onClick={() => {
@ -184,7 +209,7 @@ export default function MobileMenu({
}}
className="flex-1 px-3 py-2.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
>
Create account
{t('header.createAccount')}
</button>
</div>
)}

View file

@ -10,7 +10,10 @@ export function Slider({ className, ...props }: SliderProps) {
className={`relative flex w-full touch-none select-none items-center ${className || ''}`}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200 dark:bg-navy-700">
<SliderPrimitive.Track
className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200 dark:bg-navy-700"
onPointerDown={(e) => e.preventDefault()}
>
<SliderPrimitive.Range className="absolute h-full bg-teal-600" />
</SliderPrimitive.Track>
{props.value?.map((_, i) => (

View file

@ -34,7 +34,11 @@ export default function UpgradeModal({
}, []);
const priceLabel =
pricePence === null ? '...' : pricePence === 0 ? t('upgrade.free') : `\u00A3${pricePence / 100}`;
pricePence === null
? '...'
: pricePence === 0
? t('upgrade.free')
: `\u00A3${pricePence / 100}`;
const isFree = pricePence === 0;
const handleUpgrade = async () => {
@ -63,9 +67,7 @@ export default function UpgradeModal({
{/* Header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">{t('upgrade.title')}</h2>
<p className="text-warm-300 text-sm">
{t('upgrade.description')}
</p>
<p className="text-warm-300 text-sm">{t('upgrade.description')}</p>
</div>
{/* Body */}
@ -74,12 +76,12 @@ export default function UpgradeModal({
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
{priceLabel}
</span>
{!isFree && <span className="text-warm-500 dark:text-warm-400 text-lg">{t('upgrade.once')}</span>}
{!isFree && (
<span className="text-warm-500 dark:text-warm-400 text-lg">{t('upgrade.once')}</span>
)}
</div>
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
{isFree
? t('upgrade.freeForEarly')
: t('upgrade.oneTimePayment')}
{isFree ? t('upgrade.freeForEarly') : t('upgrade.oneTimePayment')}
</p>
{isLoggedIn ? (

View file

@ -30,10 +30,7 @@ export interface AiFiltersContext {
}
interface UseAiFiltersResult {
fetchAiFilters: (
query: string,
context?: AiFiltersContext
) => Promise<AiFiltersResult | null>;
fetchAiFilters: (query: string, context?: AiFiltersContext) => Promise<AiFiltersResult | null>;
loading: boolean;
error: string | null;
errorType: AiFilterErrorType | null;
@ -47,12 +44,15 @@ function buildSummary(
travelTimeFilters: AiTravelTimeFilter[],
matchCount: number
): string {
const i18n = require('../i18n').default as { t: (key: string, opts?: Record<string, unknown>) => string };
// eslint-disable-next-line @typescript-eslint/no-var-requires
const i18n = require('../i18n').default as {
t: (key: string, opts?: Record<string, unknown>) => string;
};
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
const parts: string[] = [];
for (const [name, value] of Object.entries(filters)) {
if (name === 'Listing status') continue;
if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'number') {
parts.push(ts(name));
} else if (Array.isArray(value)) {
@ -83,10 +83,7 @@ export function useAiFilters(): UseAiFiltersResult {
const abortRef = useRef<AbortController | null>(null);
const fetchAiFilters = useCallback(
async (
query: string,
context?: AiFiltersContext
): Promise<AiFiltersResult | null> => {
async (query: string, context?: AiFiltersContext): Promise<AiFiltersResult | null> => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;

View file

@ -5,11 +5,9 @@ import { useState, useCallback } from 'react';
* @param defaultCollapsed When true, groups start collapsed (tracks expanded groups).
* When false (default), groups start expanded (tracks collapsed groups).
*/
export function useCollapsibleGroups(defaultCollapsed = false): [
(name: string) => boolean,
(name: string) => void,
(name: string) => void,
] {
export function useCollapsibleGroups(
defaultCollapsed = false
): [(name: string) => boolean, (name: string) => void, (name: string) => void] {
const [toggled, setToggled] = useState<Set<string>>(new Set());
const isExpanded = useCallback(

View file

@ -22,10 +22,12 @@ import {
MINOR_POI_ZOOM_THRESHOLD,
POI_CLUSTER_RADIUS,
POI_CLUSTER_MAX_ZOOM,
getEnumPaletteForFeature,
} from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import type { TravelTimeEntry } from './useTravelTime';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
import { PieHexExtension } from '../lib/PieHexExtension';
interface UseDeckLayersProps {
data: HexagonData[];
@ -66,6 +68,17 @@ interface ClusterPoint {
clusterId: number;
}
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
function distToRatios(dist: unknown): number[] {
if (!Array.isArray(dist) || dist.length === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
let total = 0;
for (let i = 0; i < dist.length; i++) total += (dist[i] as number) || 0;
if (total === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const r = new Array<number>(10).fill(0);
for (let i = 0; i < Math.min(dist.length, 10); i++) r[i] = ((dist[i] as number) || 0) / total;
return r;
}
export function useDeckLayers({
data,
postcodeData,
@ -134,11 +147,17 @@ export function useDeckLayers({
? colorFeatureMeta.values.length
: 0;
// --- Count ranges ---
// Per-feature color palette (uses overrides when defined)
const enumPaletteRef = useRef(
getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values)
);
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
const countRange = useMemo(() => {
if (data.length === 0) return { min: 0, max: 1 };
if (data.length === 0) return { min: 0, max: 1, total: 0 };
let min = Infinity;
let max = -Infinity;
let total = 0;
for (const d of data) {
if (viewportBounds) {
if (
@ -152,19 +171,21 @@ export function useDeckLayers({
const c = d.count as number;
if (c < min) min = c;
if (c > max) max = c;
total += c;
}
if (min === Infinity) return { min: 0, max: 1 };
if (min === max) return { min, max: min + 1 };
return { min, max };
if (min === Infinity) return { min: 0, max: 1, total: 0 };
if (min === max) return { min, max: min + 1, total };
return { min, max, total };
}, [data, viewportBounds]);
const countRangeRef = useRef(countRange);
countRangeRef.current = countRange;
const postcodeCountRange = useMemo(() => {
if (postcodeData.length === 0) return { min: 0, max: 1 };
if (postcodeData.length === 0) return { min: 0, max: 1, total: 0 };
let min = Infinity;
let max = -Infinity;
let total = 0;
for (const d of postcodeData) {
if (viewportBounds) {
const [lng, lat] = d.properties.centroid as [number, number];
@ -179,10 +200,11 @@ export function useDeckLayers({
const c = d.properties.count;
if (c < min) min = c;
if (c > max) max = c;
total += c;
}
if (min === Infinity) return { min: 0, max: 1 };
if (min === max) return { min, max: min + 1 };
return { min, max };
if (min === Infinity) return { min: 0, max: 1, total: 0 };
if (min === max) return { min, max: min + 1, total };
return { min, max, total };
}, [postcodeData, viewportBounds]);
const postcodeCountRangeRef = useRef(postcodeCountRange);
@ -291,215 +313,244 @@ export function useDeckLayers({
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
// --- Layers ---
const hexLayer = useMemo(
() =>
new H3HexagonLayer<HexagonData>({
id: 'h3-hexagons',
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
// PieHexExtension uses the canonical deck.gl v9 pattern: defaultProps with
// type:'accessor' + stepMode:'dynamic'. LayerExtension.getSubLayerProps()
// wraps accessors via getSubLayerAccessor() which unwraps __source.object,
// letting accessor functions work through CompositeLayer sublayer chains.
const hexLayer = useMemo(() => {
const isEnum = enumCountRef.current > 0;
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
if (vf && clr) {
// Travel time feature: dim hexagons with no data
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
255
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pieProps: any = isEnum
? {
extensions: [new PieHexExtension(enumPaletteRef.current)],
getCenter: (d: HexagonData) => [d.lon, d.lat],
getRatios0: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[0], r[1], r[2], r[3]];
},
getRatios1: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[4], r[5], r[6], r[7]];
},
getRatios2: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[8], r[9]];
},
updateTriggers: {
getCenter: [colorTrigger, data],
getRatios0: [colorTrigger, data],
getRatios1: [colorTrigger, data],
getRatios2: [colorTrigger, data],
},
}
: {};
// Regular feature
if (cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
255,
enumCountRef.current
);
return new H3HexagonLayer<HexagonData>({
id: isEnum ? 'h3-hexagons-pie' : 'h3-hexagons',
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr) {
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
255
);
}
// Density fallback
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
255
);
},
getLineColor: (d) => {
if (d.h3 === hoveredHexagonIdRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number];
},
getLineWidth: (d) => {
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [colorTrigger],
getLineColor: [colorTrigger],
getLineWidth: [colorTrigger],
},
extruded: false,
pickable: true,
opacity: 1,
highPrecision: true,
onClick: handleHexagonClick,
onHover: handleHexagonHover,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
}),
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
);
const postcodeLayer = useMemo(
() =>
new GeoJsonLayer<PostcodeProperties>({
id: 'postcode-polygons',
data: postcodeData as PostcodeFeature[],
getFillColor: (f) => {
const d = f.properties;
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr) {
// Travel time feature: dim postcodes with no data
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
180
);
}
// Regular feature
if (cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
180,
enumCountRef.current
);
}
if (cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
255,
enumCountRef.current,
enumPaletteRef.current
);
}
const cr = postcodeCountRangeRef.current;
const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
180
);
},
getLineColor: (f) => {
const pc = f.properties.postcode;
const dark = isDarkRef.current;
if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
number,
number,
number,
number,
];
},
getLineWidth: (f) => {
const pc = f.properties.postcode;
if (pc === hoveredPostcodeRef.current) return 2;
return 1;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [postcodeColorTrigger],
getLineColor: [postcodeColorTrigger],
getLineWidth: [postcodeColorTrigger],
},
extruded: false,
pickable: true,
onClick: handlePostcodeClick,
onHover: handlePostcodeHoverCallback,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
}),
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
);
}
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
255
);
},
getLineColor: (d) => {
if (d.h3 === hoveredHexagonIdRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number];
},
getLineWidth: (d) => {
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [colorTrigger],
getLineColor: [colorTrigger],
getLineWidth: [colorTrigger],
...(pieProps.updateTriggers || {}),
},
extruded: false,
pickable: true,
opacity: 1,
highPrecision: true,
onClick: handleHexagonClick,
onHover: handleHexagonHover,
beforeId: 'landuse_park',
...pieProps,
});
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover]);
const postcodeLayer = useMemo(() => {
return new GeoJsonLayer<PostcodeProperties>({
id: 'postcode-polygons',
data: postcodeData as PostcodeFeature[],
getFillColor: (f) => {
const d = f.properties;
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr) {
// Travel time feature: dim postcodes with no data
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor(
ttVal as number,
ttMin as number,
ttMax as number,
clr,
fr,
0,
densityGradientRef.current,
dark,
180
);
}
// Regular feature (for enum, the extension overrides this color)
if (cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
180,
enumCountRef.current,
enumPaletteRef.current
);
}
}
const cr = postcodeCountRangeRef.current;
const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min);
return getFeatureFillColor(
null,
undefined,
undefined,
null,
null,
t,
densityGradientRef.current,
dark,
180
);
},
getLineColor: (f) => {
const pc = f.properties.postcode;
const dark = isDarkRef.current;
if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
number,
number,
number,
number,
];
},
getLineWidth: (f) => {
const pc = f.properties.postcode;
if (pc === hoveredPostcodeRef.current) return 2;
return 1;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [postcodeColorTrigger],
getLineColor: [postcodeColorTrigger],
getLineWidth: [postcodeColorTrigger],
},
extruded: false,
pickable: true,
onClick: handlePostcodeClick,
onHover: handlePostcodeHoverCallback,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
});
}, [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]);
const postcodeLabelsLayer = useMemo(
() =>

View file

@ -71,7 +71,14 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, [handleUndo]);
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
setFilters((prev) => ({ ...prev, [name]: value }));
setFilters((prev) => {
if (Array.isArray(value) && value.length === 0) {
const next = { ...prev };
delete next[name];
return next;
}
return { ...prev, [name]: value };
});
}, []);
const handleRemoveFilter = useCallback((name: string) => {

View file

@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { latLngToCell } from 'h3-js';
import { trackEvent } from '../lib/analytics';
import type {
FeatureMeta,
@ -287,25 +288,68 @@ export function useHexagonSelection({
return () => {
cancelled = true;
};
}, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats, rightPaneTab, fetchHexagonProperties, fetchPostcodeProperties]);
}, [
filterStr,
selectedHexagon,
fetchHexagonStats,
fetchPostcodeStats,
rightPaneTab,
fetchHexagonProperties,
fetchPostcodeProperties,
]);
const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry) => {
(postcode: string, geometry: PostcodeGeometry, lat?: number, lng?: number) => {
trackEvent('Postcode Search');
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
setSelectedPostcodeGeometry(geometry);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setRightPaneTab('area');
setLoadingAreaStats(true);
// First try the postcode; if it has no properties, fall back to hexagons
fetchPostcodeStats(postcode)
.then((stats) => setAreaStats(stats))
.then(async (stats) => {
if (stats.count > 0) {
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
return;
}
// No properties in this postcode — fall back to hexagons
if (lat == null || lng == null) {
// No coordinates available, show empty postcode anyway
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
return;
}
// Try progressively coarser H3 resolutions until we find >1 property
const resolutions = [9, 8, 7, 6, 5];
for (const res of resolutions) {
const h3 = latLngToCell(lat, lng, res);
const hexStats = await fetchHexagonStats(h3, res);
if (hexStats.count > 1) {
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: res });
setSelectedPostcodeGeometry(null);
setAreaStats(hexStats);
return;
}
}
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
const h3 = latLngToCell(lat, lng, 9);
const fallbackStats = await fetchHexagonStats(h3, 9);
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: 9 });
setSelectedPostcodeGeometry(null);
setAreaStats(fallbackStats);
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
},
[resolution, fetchPostcodeStats]
[resolution, fetchPostcodeStats, fetchHexagonStats]
);
return {

View file

@ -75,6 +75,12 @@ export function useMapData({
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
// Determine if the current viewFeature is an enum (for enum_dist param)
const viewFeatureIsEnum = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature)?.type === 'enum' : false),
[viewFeature, features]
);
const buildFilterParam = useCallback(
(): string => buildFilterString(filters, features),
[filters, features]
@ -134,6 +140,7 @@ export function useMapData({
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
@ -151,6 +158,7 @@ export function useMapData({
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
@ -168,7 +176,18 @@ export function useMapData({
dragAbortRef.current = null;
}
};
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam, buildTravelParam]);
}, [
activeFeature,
bounds,
resolution,
filters,
features,
usePostcodeView,
travelParam,
buildTravelParam,
viewFeature,
viewFeatureIsEnum,
]);
// Fetch hexagons or postcodes when bounds/filters change
useEffect(() => {
@ -196,6 +215,7 @@ export function useMapData({
if (travelParam) {
params.set('travel', travelParam);
}
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
const res = await fetch(
apiUrl('postcodes', params),
authHeaders({
@ -226,6 +246,7 @@ export function useMapData({
if (travelParam) {
params.set('travel', travelParam);
}
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
const res = await fetch(
apiUrl('hexagons', params),
authHeaders({
@ -268,7 +289,16 @@ export function useMapData({
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
}, [
resolution,
bounds,
filters,
buildFilterParam,
viewFeature,
viewFeatureIsEnum,
usePostcodeView,
travelParam,
]);
// Use drag data when it matches the current view feature, otherwise fall back to rawData
const data =

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useLayoutEffect } from 'react';
interface PaneResizeHandlers {
onPointerDown: (e: React.PointerEvent) => void;
@ -18,13 +18,29 @@ export function usePaneResize(
const targetRef = useRef<HTMLElement | null>(null);
const containerOffsetRef = useRef(0);
const containerSizeRef = useRef(0);
const rafRef = useRef<number | null>(null);
const isVertical = side === 'top' || side === 'bottom';
const styleProp = isVertical ? 'height' : 'width';
const targetCallbackRef = useCallback((el: HTMLElement | null) => {
targetRef.current = el;
}, []);
const targetCallbackRef = useCallback(
(el: HTMLElement | null) => {
targetRef.current = el;
if (el) {
el.style[styleProp] = `${liveSizeRef.current}px`;
}
},
[styleProp]
);
// Keep DOM in sync when React state commits (e.g. on pointerUp).
// This ensures the ref-managed element always reflects the latest size
// without relying on React-controlled style props.
useLayoutEffect(() => {
if (targetRef.current) {
targetRef.current.style[styleProp] = `${size}px`;
}
}, [size, styleProp]);
const computeSize = useCallback(
(e: React.PointerEvent): number => {
@ -65,12 +81,21 @@ export function usePaneResize(
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingRef.current) return;
const newSize = computeSize(e);
liveSizeRef.current = newSize;
liveSizeRef.current = computeSize(e);
if (targetRef.current) {
targetRef.current.style[styleProp] = `${newSize}px`;
// Batch DOM updates to once per animation frame — on mobile, pointermove
// can fire multiple times per frame, and each direct style.height write
// forces a synchronous reflow that desynchronises MapLibre and deck.gl.
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
if (targetRef.current) {
targetRef.current.style[styleProp] = `${liveSizeRef.current}px`;
}
});
}
} else {
setSize(newSize);
setSize(liveSizeRef.current);
}
},
[computeSize, styleProp]
@ -78,8 +103,16 @@ export function usePaneResize(
const handlePointerUp = useCallback(() => {
draggingRef.current = false;
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
// Apply final size synchronously so the commit is immediate
if (targetRef.current) {
targetRef.current.style[styleProp] = `${liveSizeRef.current}px`;
}
setSize(liveSizeRef.current);
}, []);
}, [styleProp]);
return [
size,

View file

@ -12,11 +12,7 @@ export interface SavedPropertyData {
energyRating?: string;
price?: number;
estimatedPrice?: number;
askingPrice?: number;
askingRent?: number;
bedrooms?: number;
floorArea?: number;
listingUrl?: string;
}
export interface SavedProperty {
@ -84,11 +80,7 @@ export function useSavedProperties(userId: string | null) {
energyRating: property.current_energy_rating,
price: getNum(property, 'Last known price'),
estimatedPrice: getNum(property, 'Estimated current price'),
askingPrice: getNum(property, 'Asking price'),
askingRent: getNum(property, 'Asking rent (monthly)'),
bedrooms: getNum(property, 'Bedrooms'),
floorArea: getNum(property, 'Total floor area (sqm)'),
listingUrl: property.listing_url || undefined,
};
await pb.collection('saved_properties').create({

View file

@ -35,12 +35,22 @@ export function useTranslatedModes() {
const { t } = useTranslation();
const label = useCallback(
(mode: TransportMode): string =>
({ car: t('travel.modeCar'), bicycle: t('travel.modeBicycle'), walking: t('travel.modeWalking'), transit: t('travel.modeTransit') })[mode],
({
car: t('travel.modeCar'),
bicycle: t('travel.modeBicycle'),
walking: t('travel.modeWalking'),
transit: t('travel.modeTransit'),
})[mode],
[t]
);
const desc = useCallback(
(mode: TransportMode): string =>
({ car: t('travel.modeCarDesc'), bicycle: t('travel.modeBicycleDesc'), walking: t('travel.modeWalkingDesc'), transit: t('travel.modeTransitDesc') })[mode],
({
car: t('travel.modeCarDesc'),
bicycle: t('travel.modeBicycleDesc'),
walking: t('travel.modeWalkingDesc'),
transit: t('travel.modeTransitDesc'),
})[mode],
[t]
);
return { label, desc };

View file

@ -8,14 +8,54 @@ const STORAGE_KEY = 'tutorial_completed';
export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked = false) {
const { t } = useTranslation();
const steps: Step[] = useMemo(() => [
{ target: '[data-tutorial="filters"]', title: t('tutorial.step1Title'), content: t('tutorial.step1Content'), placement: 'right' as const, disableBeacon: true },
{ target: '[data-tutorial="ai-filters"]', title: t('tutorial.step2Title'), content: t('tutorial.step2Content'), placement: 'right' as const, disableBeacon: true },
{ target: '[data-tutorial="map"]', title: t('tutorial.step3Title'), content: t('tutorial.step3Content'), placement: 'bottom' as const, disableBeacon: true },
{ target: '[data-tutorial="search"]', title: t('tutorial.step4Title'), content: t('tutorial.step4Content'), placement: 'bottom' as const, disableBeacon: true },
{ target: '[data-tutorial="right-pane"]', title: t('tutorial.step5Title'), content: t('tutorial.step5Content'), placement: 'left' as const, disableBeacon: true },
{ target: '[data-tutorial="poi-button"]', title: t('tutorial.step6Title'), content: t('tutorial.step6Content'), placement: 'left' as const, disableBeacon: true, styles: { tooltip: { transform: 'translateY(-50px)' } } },
], [t]);
const steps: Step[] = useMemo(
() => [
{
target: '[data-tutorial="filters"]',
title: t('tutorial.step1Title'),
content: t('tutorial.step1Content'),
placement: 'right' as const,
disableBeacon: true,
},
{
target: '[data-tutorial="ai-filters"]',
title: t('tutorial.step2Title'),
content: t('tutorial.step2Content'),
placement: 'right' as const,
disableBeacon: true,
},
{
target: '[data-tutorial="map"]',
title: t('tutorial.step3Title'),
content: t('tutorial.step3Content'),
placement: 'bottom' as const,
disableBeacon: true,
},
{
target: '[data-tutorial="search"]',
title: t('tutorial.step4Title'),
content: t('tutorial.step4Content'),
placement: 'bottom' as const,
disableBeacon: true,
},
{
target: '[data-tutorial="right-pane"]',
title: t('tutorial.step5Title'),
content: t('tutorial.step5Content'),
placement: 'left' as const,
disableBeacon: true,
},
{
target: '[data-tutorial="poi-button"]',
title: t('tutorial.step6Title'),
content: t('tutorial.step6Content'),
placement: 'left' as const,
disableBeacon: true,
styles: { tooltip: { transform: 'translateY(-50px)' } },
},
],
[t]
);
const [run, setRun] = useState(() => {
if (isMobile) return false;
@ -50,6 +90,6 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
handleCallback,
resetTutorial,
}),
[shouldRun, handleCallback, resetTutorial]
[steps, shouldRun, handleCallback, resetTutorial]
);
}

View file

@ -6,62 +6,68 @@ import i18n from 'i18next';
* English descriptions are NOT here the server is the single source of truth
* for English. Fix a typo in features.rs and it propagates automatically.
*
* Non-English translations are keyed by the stable feature name, so they're
* Non-English translations are keyed by the stable feature name, so theyre
* independent of the English description text. If a translation is missing,
* tsDesc() falls back to the server's English description.
* tsDesc() falls back to the servers English description.
*/
const descriptions: Record<string, Record<string, string>> = {
fr: {
'Listing status': 'Indique si le bien provient de ventes historiques, est en vente ou en location',
'Property type': 'Type de bien : individuel, jumelé, mitoyen, appartement ou autre',
'Leasehold/Freehold': 'Indique si le bien est en bail ou en pleine propriété',
'Last known price': 'Dernier prix de vente enregistré au Land Registry',
'Estimated current price': 'Estimation du prix actuel ajusté à linflation',
'Asking price': 'Prix demandé pour les biens actuellement en vente',
'Price per sqm': 'Prix de vente divisé par la surface totale',
'Est. price per sqm': 'Prix actuel estimé divisé par la surface totale',
'Asking price per sqm': 'Prix demandé divisé par la surface totale',
'Estimated monthly rent': 'Loyer mensuel privé médian pour le secteur',
'Asking rent (monthly)': 'Loyer mensuel affiché pour les biens en location',
'Total floor area (sqm)': 'Surface intérieure issue du diagnostic EPC',
'Number of bedrooms & living rooms': 'Nombre de pièces habitables selon le diagnostic EPC',
'Bedrooms': 'Nombre de chambres selon lannonce en ligne',
'Bathrooms': 'Nombre de salles de bain selon lannonce en ligne',
'Construction year': 'Année de construction estimée selon lEPC',
'Date of last transaction': 'Date de la dernière vente enregistrée au Land Registry',
'Listing date': 'Date de première mise en ligne du bien',
'Former council house': 'Indique si le bien a été répertorié comme logement social',
'Current energy rating': 'Classement énergétique EPC actuel (A = meilleur, G = pire)',
'Potential energy rating': 'Classement EPC potentiel si toutes les améliorations recommandées étaient réalisées',
'Potential energy rating':
'Classement EPC potentiel si toutes les améliorations recommandées étaient réalisées',
'Interior height (m)': 'Hauteur moyenne détage selon le diagnostic EPC',
'Distance to nearest train or tube station (km)': 'Distance à la gare ou station de métro la plus proche',
'Good+ primary schools within 2km': 'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ secondary schools within 2km': 'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ primary schools within 5km': 'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Good+ secondary schools within 5km': 'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Education, Skills and Training Score': 'Score de qualité éducative du secteur (plus élevé = meilleur)',
'Distance to nearest train or tube station (km)':
'Distance à la gare ou station de métro la plus proche',
'Good+ primary schools within 2km':
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ secondary schools within 2km':
'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ primary schools within 5km':
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Good+ secondary schools within 5km':
'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Education, Skills and Training Score':
'Score de qualité éducative du secteur (plus élevé = meilleur)',
'Income Score (rate)': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)',
'Employment Score (rate)': 'Taux de précarité demploi, inversé (plus élevé = moins précaire)',
'Health Deprivation and Disability Score': 'Score de santé et handicap (plus élevé = meilleurs résultats)',
'Living Environment Score': 'Qualité de lenvironnement intérieur et extérieur (plus élevé = meilleur)',
'Health Deprivation and Disability Score':
'Score de santé et handicap (plus élevé = meilleurs résultats)',
'Living Environment Score':
'Qualité de lenvironnement intérieur et extérieur (plus élevé = meilleur)',
'Indoors Sub-domain Score': 'Qualité et état du logement (plus élevé = meilleur)',
'Outdoors Sub-domain Score': 'Qualité de lair et sécurité routière (plus élevé = meilleur)',
'Serious crime per 1k residents (avg/yr)': 'Taux de crimes graves pour 1 000 habitants par an',
'Minor crime per 1k residents (avg/yr)': 'Taux de délits mineurs pour 1 000 habitants par an',
'Serious crime (avg/yr)': 'Agrégat des catégories de crimes graves par an',
'Minor crime (avg/yr)': 'Agrégat des catégories de délits mineurs par an',
'Violence and sexual offences (avg/yr)': 'Moyenne annuelle des violences et infractions sexuelles dans le secteur',
'Violence and sexual offences (avg/yr)':
'Moyenne annuelle des violences et infractions sexuelles dans le secteur',
'Burglary (avg/yr)': 'Moyenne annuelle des cambriolages dans le secteur',
'Robbery (avg/yr)': 'Moyenne annuelle des vols avec violence dans le secteur',
'Vehicle crime (avg/yr)': 'Moyenne annuelle des crimes liés aux véhicules dans le secteur',
'Anti-social behaviour (avg/yr)': 'Moyenne annuelle des comportements antisociaux dans le secteur',
'Criminal damage and arson (avg/yr)': 'Moyenne annuelle des dégradations et incendies criminels dans le secteur',
'Anti-social behaviour (avg/yr)':
'Moyenne annuelle des comportements antisociaux dans le secteur',
'Criminal damage and arson (avg/yr)':
'Moyenne annuelle des dégradations et incendies criminels dans le secteur',
'Other theft (avg/yr)': 'Moyenne annuelle des autres vols dans le secteur',
'Theft from the person (avg/yr)': 'Moyenne annuelle des vols à la personne dans le secteur',
'Shoplifting (avg/yr)': 'Moyenne annuelle des vols à létalage dans le secteur',
'Bicycle theft (avg/yr)': 'Moyenne annuelle des vols de vélos dans le secteur',
'Drugs (avg/yr)': 'Moyenne annuelle des infractions liées aux stupéfiants dans le secteur',
'Possession of weapons (avg/yr)': 'Moyenne annuelle des infractions de possession darmes dans le secteur',
'Possession of weapons (avg/yr)':
'Moyenne annuelle des infractions de possession darmes dans le secteur',
'Public order (avg/yr)': 'Moyenne annuelle des troubles à lordre public dans le secteur',
'Other crime (avg/yr)': 'Moyenne annuelle des autres crimes dans le secteur',
'Median age': 'Âge médian de la population locale',
@ -69,101 +75,132 @@ const descriptions: Record<string, Record<string, string>> = {
'% South Asian': 'Pourcentage de la population se déclarant Sud-Asiatique',
'% Black': 'Pourcentage de la population se déclarant Noire',
'% East Asian': 'Pourcentage de la population se déclarant Est-Asiatique',
'% Mixed': 'Pourcentage de la population se déclarant Métisse ou de plusieurs groupes ethniques',
'% Mixed':
'Pourcentage de la population se déclarant Métisse ou de plusieurs groupes ethniques',
'% Other': 'Pourcentage de la population se déclarant dun autre groupe ethnique',
'Winning party':
'Parti vainqueur dans la circonscription lors des élections générales de 2024',
'Voter turnout (%)':
'Pourcentage délecteurs inscrits ayant voté aux élections générales de 2024',
'Majority (%)':
'Marge de victoire en pourcentage des votes valides aux élections générales de 2024',
'% Labour': 'Part des voix travaillistes aux élections générales de 2024',
'% Conservative': 'Part des voix conservatrices aux élections générales de 2024',
'% Liberal Democrat': 'Part des voix libérales-démocrates aux élections générales de 2024',
'% Reform UK': 'Part des voix Reform UK aux élections générales de 2024',
'% Green': 'Part des voix vertes aux élections générales de 2024',
'% Other parties': 'Part des voix combinée de tous les autres partis et indépendants',
'Distance to nearest park (km)': 'Distance au parc ou espace vert le plus proche',
'Number of parks within 2km': 'Nombre de parcs et espaces verts à moins de 2 km',
'Number of parks within 1km': 'Nombre de parcs et espaces verts à moins de 1 km',
'Number of restaurants within 2km': 'Nombre de restaurants et cafés à moins de 2 km',
'Number of grocery shops and supermarkets within 2km': 'Nombre dépiceries et supermarchés à moins de 2 km',
'Number of grocery shops and supermarkets within 2km':
'Nombre dépiceries et supermarchés à moins de 2 km',
'Noise (dB)': 'Niveau de bruit routier au code postal en décibels (Lden)',
'Max available download speed (Mbps)': 'Débit descendant maximal disponible au code postal',
},
de: {
'Listing status': 'Ob die Immobilie aus historischen Verkäufen stammt, aktuell zum Verkauf oder zur Miete steht',
'Property type': 'Immobilientyp: freistehend, Doppelhaushälfte, Reihenhaus, Wohnung oder sonstige',
'Property type':
'Immobilientyp: freistehend, Doppelhaushälfte, Reihenhaus, Wohnung oder sonstige',
'Leasehold/Freehold': 'Ob die Immobilie Erbbaurecht oder Volleigentum ist',
'Last known price': 'Letzter Verkaufspreis laut Land Registry',
'Estimated current price': 'Inflationsbereinigter Schätzwert der Immobilie',
'Asking price': 'Angebotspreis für aktuell zum Verkauf stehende Immobilien',
'Price per sqm': 'Verkaufspreis geteilt durch die Gesamtfläche',
'Est. price per sqm': 'Geschätzter aktueller Preis geteilt durch die Gesamtfläche',
'Asking price per sqm': 'Angebotspreis geteilt durch die Gesamtfläche',
'Estimated monthly rent': 'Mittlere monatliche Privatmiete in der Gegend',
'Asking rent (monthly)': 'Angebotene Monatsmiete für Mietimmobilien',
'Total floor area (sqm)': 'Wohnfläche laut EPC-Gutachten',
'Number of bedrooms & living rooms': 'Anzahl bewohnbarer Räume laut EPC-Gutachten',
'Bedrooms': 'Anzahl Schlafzimmer laut Online-Inserat',
'Bathrooms': 'Anzahl Badezimmer laut Online-Inserat',
'Construction year': 'Geschätztes Baujahr laut EPC',
'Date of last transaction': 'Datum des letzten Verkaufs laut Land Registry',
'Listing date': 'Datum der Erstveröffentlichung des Inserats',
'Former council house': 'Ob die Immobilie jemals als Sozialbau erfasst wurde',
'Current energy rating': 'Aktuelle EPC-Energieeffizienzklasse (A = beste, G = schlechteste)',
'Potential energy rating': 'Potenzielle EPC-Klasse bei Umsetzung aller empfohlenen Maßnahmen',
'Interior height (m)': 'Durchschnittliche Geschosshöhe laut EPC-Gutachten',
'Distance to nearest train or tube station (km)': 'Entfernung zum nächsten Bahn- oder U-Bahnhof',
'Good+ primary schools within 2km': 'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
'Good+ secondary schools within 2km': 'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
'Good+ primary schools within 5km': 'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km': 'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Distance to nearest train or tube station (km)':
'Entfernung zum nächsten Bahn- oder U-Bahnhof',
'Good+ primary schools within 2km':
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
'Good+ secondary schools within 2km':
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
'Good+ primary schools within 5km':
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km':
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)',
'Income Score (rate)': 'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Employment Score (rate)': 'Beschäftigungsbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Health Deprivation and Disability Score': 'Gesundheits- und Behinderungsscore (höher = bessere Ergebnisse)',
'Income Score (rate)':
'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Employment Score (rate)':
'Beschäftigungsbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Health Deprivation and Disability Score':
'Gesundheits- und Behinderungsscore (höher = bessere Ergebnisse)',
'Living Environment Score': 'Qualität der Innen- und Außenumgebung (höher = besser)',
'Indoors Sub-domain Score': 'Wohnqualität und -zustand (höher = besser)',
'Outdoors Sub-domain Score': 'Luftqualität und Verkehrssicherheit (höher = besser)',
'Serious crime per 1k residents (avg/yr)': 'Rate schwerer Straftaten pro 1.000 Einwohner pro Jahr',
'Minor crime per 1k residents (avg/yr)': 'Rate leichter Straftaten pro 1.000 Einwohner pro Jahr',
'Serious crime per 1k residents (avg/yr)':
'Rate schwerer Straftaten pro 1.000 Einwohner pro Jahr',
'Minor crime per 1k residents (avg/yr)':
'Rate leichter Straftaten pro 1.000 Einwohner pro Jahr',
'Serious crime (avg/yr)': 'Summe der schweren Straftaten-Kategorien pro Jahr',
'Minor crime (avg/yr)': 'Summe der leichten Straftaten-Kategorien pro Jahr',
'Violence and sexual offences (avg/yr)': 'Jährlicher Durchschnitt der Gewalt- und Sexualdelikte in der Gegend',
'Violence and sexual offences (avg/yr)':
'Jährlicher Durchschnitt der Gewalt- und Sexualdelikte in der Gegend',
'Burglary (avg/yr)': 'Jährlicher Durchschnitt der Einbrüche in der Gegend',
'Robbery (avg/yr)': 'Jährlicher Durchschnitt der Raubüberfälle in der Gegend',
'Vehicle crime (avg/yr)': 'Jährlicher Durchschnitt der Fahrzeugkriminalität in der Gegend',
'Anti-social behaviour (avg/yr)': 'Jährlicher Durchschnitt des antisozialen Verhaltens in der Gegend',
'Criminal damage and arson (avg/yr)': 'Jährlicher Durchschnitt der Sachbeschädigungen und Brandstiftungen in der Gegend',
'Anti-social behaviour (avg/yr)':
'Jährlicher Durchschnitt des antisozialen Verhaltens in der Gegend',
'Criminal damage and arson (avg/yr)':
'Jährlicher Durchschnitt der Sachbeschädigungen und Brandstiftungen in der Gegend',
'Other theft (avg/yr)': 'Jährlicher Durchschnitt des sonstigen Diebstahls in der Gegend',
'Theft from the person (avg/yr)': 'Jährlicher Durchschnitt des Taschendiebstahls in der Gegend',
'Shoplifting (avg/yr)': 'Jährlicher Durchschnitt des Ladendiebstahls in der Gegend',
'Bicycle theft (avg/yr)': 'Jährlicher Durchschnitt des Fahrraddiebstahls in der Gegend',
'Drugs (avg/yr)': 'Jährlicher Durchschnitt der Drogendelikte in der Gegend',
'Possession of weapons (avg/yr)': 'Jährlicher Durchschnitt der Waffenbesitzdelikte in der Gegend',
'Public order (avg/yr)': 'Jährlicher Durchschnitt der Störungen der öffentlichen Ordnung in der Gegend',
'Possession of weapons (avg/yr)':
'Jährlicher Durchschnitt der Waffenbesitzdelikte in der Gegend',
'Public order (avg/yr)':
'Jährlicher Durchschnitt der Störungen der öffentlichen Ordnung in der Gegend',
'Other crime (avg/yr)': 'Jährlicher Durchschnitt sonstiger Straftaten in der Gegend',
'Median age': 'Medianalter der lokalen Bevölkerung',
'% White': 'Anteil der Bevölkerung, der sich als Weiß identifiziert',
'% South Asian': 'Anteil der Bevölkerung, der sich als Südasiatisch identifiziert',
'% Black': 'Anteil der Bevölkerung, der sich als Schwarz identifiziert',
'% East Asian': 'Anteil der Bevölkerung, der sich als Ostasiatisch identifiziert',
'% Mixed': 'Anteil der Bevölkerung, der sich als gemischt oder mehreren ethnischen Gruppen zugehörig identifiziert',
'% Mixed':
'Anteil der Bevölkerung, der sich als gemischt oder mehreren ethnischen Gruppen zugehörig identifiziert',
'% Other': 'Anteil der Bevölkerung, der sich einer anderen ethnischen Gruppe zuordnet',
'Winning party':
'Siegreiche Partei im Wahlkreis bei der Parlamentswahl 2024',
'Voter turnout (%)':
'Anteil der registrierten Wähler, die bei der Parlamentswahl 2024 gewählt haben',
'Majority (%)':
'Gewinnspanne als Prozentsatz der gültigen Stimmen bei der Parlamentswahl 2024',
'% Labour': 'Labour-Stimmenanteil bei der Parlamentswahl 2024',
'% Conservative': 'Stimmenanteil der Konservativen bei der Parlamentswahl 2024',
'% Liberal Democrat': 'Stimmenanteil der Liberaldemokraten bei der Parlamentswahl 2024',
'% Reform UK': 'Stimmenanteil von Reform UK bei der Parlamentswahl 2024',
'% Green': 'Stimmenanteil der Grünen bei der Parlamentswahl 2024',
'% Other parties': 'Kombinierter Stimmenanteil aller anderen Parteien und Unabhängigen',
'Distance to nearest park (km)': 'Entfernung zum nächsten Park oder Grünfläche',
'Number of parks within 2km': 'Anzahl Parks und Grünflächen im Umkreis von 2 km',
'Number of parks within 1km': 'Anzahl Parks und Grünflächen im Umkreis von 1 km',
'Number of restaurants within 2km': 'Anzahl Restaurants und Cafés im Umkreis von 2 km',
'Number of grocery shops and supermarkets within 2km': 'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
'Number of grocery shops and supermarkets within 2km':
'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
'Noise (dB)': 'Straßenlärmpegel an der Postleitzahl in Dezibel (Lden)',
'Max available download speed (Mbps)': 'Maximal verfügbare Breitband-Downloadgeschwindigkeit an der Postleitzahl',
'Max available download speed (Mbps)':
'Maximal verfügbare Breitband-Downloadgeschwindigkeit an der Postleitzahl',
},
zh: {
'Listing status': '该房产是历史销售、当前在售还是出租',
'Property type': '房产类型:独立式、半独立式、联排、公寓或其他',
'Leasehold/Freehold': '该房产是租赁产权还是永久产权',
'Last known price': 'Land Registry记录的最近一次售价',
'Estimated current price': '经通胀调整后的当前估计价值',
'Asking price': '当前在售房产的挂牌价',
'Price per sqm': '售价除以总建筑面积',
'Est. price per sqm': '估计当前价格除以总建筑面积',
'Asking price per sqm': '挂牌价除以总建筑面积',
'Estimated monthly rent': '当地私人租赁的中位月租',
'Asking rent (monthly)': '当前出租房产的挂牌月租',
'Total floor area (sqm)': 'EPC评估的室内建筑面积',
'Number of bedrooms & living rooms': 'EPC评估的宜居房间数',
'Bedrooms': '在线房源中的卧室数量',
'Bathrooms': '在线房源中的浴室数量',
'Construction year': 'EPC评估的建造年份',
'Date of last transaction': 'Land Registry记录的最近一次销售日期',
'Listing date': '房产首次在线上市的日期',
'Former council house': '该房产是否曾被记录为公共住房',
'Current energy rating': '当前EPC能效评级A = 最佳G = 最差)',
'Potential energy rating': '实施所有建议改进后的潜在EPC评级',
@ -205,45 +242,56 @@ const descriptions: Record<string, Record<string, string>> = {
'% East Asian': '东亚裔人口比例',
'% Mixed': '混血或多族裔人口比例',
'% Other': '其他族裔人口比例',
'Winning party': '2024年大选中该选区获胜的政党',
'Voter turnout (%)': '2024年大选中登记选民的投票率',
'Majority (%)': '2024年大选中获胜者的得票优势占有效票的百分比',
'% Labour': '2024年大选中工党得票率',
'% Conservative': '2024年大选中保守党得票率',
'% Liberal Democrat': '2024年大选中自由民主党得票率',
'% Reform UK': '2024年大选中英国改革党得票率',
'% Green': '2024年大选中绿党得票率',
'% Other parties': '所有其他政党和独立候选人的综合得票率',
'Distance to nearest park (km)': '到最近公园或绿地的距离',
'Number of parks within 2km': '2公里内公园和绿地数量',
'Number of parks within 1km': '1公里内公园和绿地数量',
'Number of restaurants within 2km': '2公里内餐厅和咖啡馆数量',
'Number of grocery shops and supermarkets within 2km': '2公里内食品店和超市数量',
'Noise (dB)': '该邮编的道路噪音水平分贝Lden',
'Max available download speed (Mbps)': '该邮编可用的最大宽带下载速度',
},
hu: {
'Listing status': 'Az ingatlan korábbi eladásból származik, jelenleg eladó vagy kiadó',
'Property type': 'Ingatlantípus: különálló, ikerház, sorház, lakás vagy egyéb',
'Leasehold/Freehold': 'Az ingatlan bérleti jogú vagy teljes tulajdonú',
'Last known price': 'A Land Registry-ben rögzített utolsó eladási ár',
'Estimated current price': 'Inflációval korrigált becsült jelenlegi érték',
'Asking price': 'A jelenleg eladásra kínált ingatlanok irányára',
'Price per sqm': 'Eladási ár osztva az összes alapterülettel',
'Est. price per sqm': 'Becsült jelenlegi ár osztva az összes alapterülettel',
'Asking price per sqm': 'Irányár osztva az összes alapterülettel',
'Estimated monthly rent': 'A környék medián havi magánbérleti díja',
'Asking rent (monthly)': 'A kiadó ingatlanok hirdetett havi bérleti díja',
'Total floor area (sqm)': 'Az EPC felmérésből származó belső alapterület',
'Number of bedrooms & living rooms': 'Lakószobák száma az EPC felmérés alapján',
'Bedrooms': 'Hálószobák száma az online hirdetés szerint',
'Bathrooms': 'Fürdőszobák száma az online hirdetés szerint',
'Construction year': 'Becsült építési év az EPC alapján',
'Date of last transaction': 'Az utolsó eladás dátuma a Land Registry szerint',
'Listing date': 'Az ingatlan első online megjelenésének dátuma',
'Former council house': 'Az ingatlan szerepelt-e valaha önkormányzati lakásként',
'Current energy rating': 'Jelenlegi EPC energiabesorolás (A = legjobb, G = legrosszabb)',
'Potential energy rating': 'Potenciális EPC besorolás az összes javasolt fejlesztés elvégzése után',
'Potential energy rating':
'Potenciális EPC besorolás az összes javasolt fejlesztés elvégzése után',
'Interior height (m)': 'Átlagos belmagasság az EPC felmérés alapján',
'Distance to nearest train or tube station (km)': 'Távolság a legközelebbi vasút- vagy metróállomásig',
'Good+ primary schools within 2km': 'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
'Good+ secondary schools within 2km': 'Ofsted által Jó vagy Kiváló minősítésű középiskolák 2 km-en belül',
'Good+ primary schools within 5km': 'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
'Good+ secondary schools within 5km': 'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
'Education, Skills and Training Score': 'A környék oktatási minőségi pontszáma (magasabb = jobb)',
'Distance to nearest train or tube station (km)':
'Távolság a legközelebbi vasút- vagy metróállomásig',
'Good+ primary schools within 2km':
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
'Good+ secondary schools within 2km':
'Ofsted által Jó vagy Kiváló minősítésű középiskolák 2 km-en belül',
'Good+ primary schools within 5km':
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
'Good+ secondary schools within 5km':
'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
'Education, Skills and Training Score':
'A környék oktatási minőségi pontszáma (magasabb = jobb)',
'Income Score (rate)': 'Jövedelmi deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
'Employment Score (rate)': 'Foglalkoztatási deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
'Health Deprivation and Disability Score': 'Egészségügyi és fogyatékossági pontszám (magasabb = jobb eredmények)',
'Employment Score (rate)':
'Foglalkoztatási deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
'Health Deprivation and Disability Score':
'Egészségügyi és fogyatékossági pontszám (magasabb = jobb eredmények)',
'Living Environment Score': 'Belső és külső környezet minősége (magasabb = jobb)',
'Indoors Sub-domain Score': 'Lakásminőség és állapot (magasabb = jobb)',
'Outdoors Sub-domain Score': 'Levegőminőség és közlekedésbiztonság (magasabb = jobb)',
@ -251,7 +299,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Minor crime per 1k residents (avg/yr)': 'Kisebb bűncselekmények aránya 1000 lakosra évente',
'Serious crime (avg/yr)': 'Súlyos bűncselekményi kategóriák éves összesítése',
'Minor crime (avg/yr)': 'Kisebb bűncselekményi kategóriák éves összesítése',
'Violence and sexual offences (avg/yr)': 'Erőszakos és szexuális bűncselekmények éves átlaga a környéken',
'Violence and sexual offences (avg/yr)':
'Erőszakos és szexuális bűncselekmények éves átlaga a környéken',
'Burglary (avg/yr)': 'Betörések éves átlaga a környéken',
'Robbery (avg/yr)': 'Rablások éves átlaga a környéken',
'Vehicle crime (avg/yr)': 'Gépjárművel kapcsolatos bűncselekmények éves átlaga a környéken',
@ -262,7 +311,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Shoplifting (avg/yr)': 'Bolti lopások éves átlaga a környéken',
'Bicycle theft (avg/yr)': 'Kerékpárlopások éves átlaga a környéken',
'Drugs (avg/yr)': 'Kábítószerrel kapcsolatos bűncselekmények éves átlaga a környéken',
'Possession of weapons (avg/yr)': 'Fegyvertartással kapcsolatos bűncselekmények éves átlaga a környéken',
'Possession of weapons (avg/yr)':
'Fegyvertartással kapcsolatos bűncselekmények éves átlaga a környéken',
'Public order (avg/yr)': 'Közrend elleni bűncselekmények éves átlaga a környéken',
'Other crime (avg/yr)': 'Egyéb bűncselekmények éves átlaga a környéken',
'Median age': 'A helyi lakosság medián életkora',
@ -272,12 +322,26 @@ const descriptions: Record<string, Record<string, string>> = {
'% East Asian': 'A kelet-ázsiaiként azonosított lakosság aránya',
'% Mixed': 'A vegyes vagy több etnikai csoporthoz tartozóként azonosított lakosság aránya',
'% Other': 'Az egyéb etnikai csoportba tartozóként azonosított lakosság aránya',
'Winning party':
'A 2024-es parlamenti választáson a választókerületben győztes párt',
'Voter turnout (%)':
'A regisztrált választók szavazási aránya a 2024-es parlamenti választáson',
'Majority (%)':
'Győzelmi előny az érvényes szavazatok százalékában a 2024-es parlamenti választáson',
'% Labour': 'A Munkáspárt szavazataránya a 2024-es parlamenti választáson',
'% Conservative': 'A Konzervatív Párt szavazataránya a 2024-es parlamenti választáson',
'% Liberal Democrat': 'A Liberális Demokraták szavazataránya a 2024-es parlamenti választáson',
'% Reform UK': 'A Reform UK szavazataránya a 2024-es parlamenti választáson',
'% Green': 'A Zöld Párt szavazataránya a 2024-es parlamenti választáson',
'% Other parties': 'Az összes többi párt és független jelölt összesített szavazataránya',
'Distance to nearest park (km)': 'Távolság a legközelebbi parkig vagy zöldterületig',
'Number of parks within 2km': 'Parkok és zöldterületek száma 2 km-en belül',
'Number of parks within 1km': 'Parkok és zöldterületek száma 1 km-en belül',
'Number of restaurants within 2km': 'Éttermek és kávézók száma 2 km-en belül',
'Number of grocery shops and supermarkets within 2km': 'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
'Number of grocery shops and supermarkets within 2km':
'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
'Noise (dB)': 'Közúti zajszint az irányítószámnál decibelben (Lden)',
'Max available download speed (Mbps)': 'Az irányítószámnál elérhető maximális szélessávú letöltési sebesség',
'Max available download speed (Mbps)':
'Az irányítószámnál elérhető maximális szélessávú letöltési sebesség',
},
};
@ -291,3 +355,13 @@ export function tsDesc(featureName: string, englishFromServer: string): string {
if (lang === 'en') return englishFromServer;
return descriptions[lang]?.[featureName] ?? englishFromServer;
}
/**
* Translate a feature detail (the longer explanatory paragraph in the info card).
* Same pattern as tsDesc: English from server, other languages from this file.
*/
export function tsDetail(featureName: string, englishFromServer: string): string {
const lang = i18n.language;
if (lang === 'en') return englishFromServer;
return descriptions[lang]?.[featureName] ?? englishFromServer;
}

View file

@ -0,0 +1,549 @@
/**
* Feature detail translations (the longer explanatory paragraph in the info card).
* Same structure as descriptions: keyed by language, then by feature name.
* English details come from the server NOT duplicated here.
*/
export const details: Record<string, Record<string, string>> = {
fr: {
'Property type':
'Provient des données HM Land Registry Price Paid et des certificats EPC. Individuelle, Semi-individuelle, Mitoyenne (inclut tous les sous-types de maisons en rangée), Appartements/Maisons duplex, ou Autre (bungalows, mobil-homes, etc.).',
'Leasehold/Freehold':
"Provient des données HM Land Registry Price Paid. Freehold signifie que vous êtes propriétaire du bâtiment et du terrain sur lequel il se trouve. Leasehold signifie que vous êtes propriétaire du bâtiment mais pas du terrain : vous disposez d'un bail accordé par le propriétaire du terrain pour un nombre d'années déterminé.",
'Last known price':
"Le dernier prix de vente enregistré pour ce bien provenant des données HM Land Registry Price Paid. Couvre les ventes résidentielles en Angleterre. Peut dater de plusieurs années si le bien n'a pas été vendu récemment.",
'Estimated current price':
"Basé sur le dernier prix de vente, ajusté en fonction des évolutions locales des prix au fil du temps à l'aide d'un indice de ventes répétées (suivi par secteur de code postal et type de bien). Si des améliorations postérieures à la vente sont détectées d'après les relevés EPC, une prime de rénovation est ajoutée. Les ventes récentes seront proches du prix d'origine ; les ventes plus anciennes font l'objet d'un ajustement plus important.",
'Price per sqm':
'Calculé en divisant le dernier prix de vente connu par la surface habitable totale indiquée dans le certificat EPC. Utile pour comparer la valeur entre des biens de tailles différentes. Disponible uniquement lorsque les données de prix et de surface existent toutes les deux.',
'Est. price per sqm':
"Calculé en divisant le prix actuel estimé et ajusté à l'inflation (y compris toute prime de rénovation) par la surface habitable totale indiquée dans le certificat EPC. Fournit une comparaison prix/superficie plus actualisée que le prix au sqm basé sur le prix de vente historique.",
'Estimated monthly rent':
"Prix médian mensuel de location provenant des statistiques sommaires du marché locatif privé de l'ONS (octobre 2022 - septembre 2023), correspondant à l'autorité locale et au nombre de chambres. Basé sur les données de locations de l'Agence d'évaluation (Valuation Office Agency).",
'Total floor area (sqm)':
"Surface habitable totale en mètres carrés telle que mesurée lors de l'évaluation du certificat de performance énergétique (EPC). Inclut toutes les pièces habitables mais exclut les garages, dépendances et espaces extérieurs.",
'Number of bedrooms & living rooms':
"Nombre total de pièces habitables (chambres et salons) tel qu'enregistré dans le certificat de performance énergétique (EPC). Les cuisines et salles de bain sont généralement exclues, sauf si elles sont suffisamment grandes pour être comptées comme pièces habitables.",
'Construction year':
"Dérivé de la tranche d'âge de construction indiquée dans l'EPC (par exemple « 1930-1949 ») en prenant le point médian. Moins précis pour les bâtiments anciens où la tranche d'âge s'étend sur plusieurs décennies.",
'Date of last transaction':
'La date de la vente enregistrée la plus récente pour ce bien, provenant des données HM Land Registry Price Paid. Stockée sous forme de date/heure dans les données ; convertie en année fractionnaire pour le filtrage et les graphiques.',
'Former council house':
"Dérivé du champ TENURE dans les données du certificat de performance énergétique (EPC). Si l'un des certificats EPC pour ce bien a enregistré le régime d'occupation comme location sociale, cela indique que le bien faisait partie du parc de logements du conseil municipal ou d'une association de logement au moment de cette inspection. Les biens qui ont été vendus ultérieurement (par exemple via le Right to Buy) conservent cet indicateur.",
'Current energy rating':
"La note d'efficacité énergétique actuelle issue du certificat de performance énergétique (EPC). Va de A (plus efficace) à G (moins efficace). Basée sur la consommation d'énergie du bien par mètre carré de surface habitable.",
'Potential energy rating':
"La note d'efficacité énergétique potentielle issue du certificat de performance énergétique (EPC), si toutes les améliorations rentables recommandées dans le rapport EPC étaient réalisées. Va de A (plus efficace) à G (moins efficace).",
'Interior height (m)':
"Hauteur intérieure moyenne (sol au plafond) en mètres telle qu'enregistrée lors de l'évaluation du certificat de performance énergétique (EPC). Calculée en divisant le volume intérieur total par la surface habitable totale.",
'Distance to nearest train or tube station (km)':
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à la gare ferroviaire ou la station de métro/tram la plus proche.",
'Good+ primary schools within 2km':
"Écoles primaires financées par l'État dans un rayon de 2km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Good+ secondary schools within 2km':
"Lycées et collèges financés par l'État dans un rayon de 2km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Good+ primary schools within 5km':
"Écoles primaires financées par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Good+ secondary schools within 5km':
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Education, Skills and Training Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Couvre les résultats scolaires, l'accès à l'enseignement supérieur, les qualifications des adultes et la maîtrise de la langue anglaise. Des scores plus élevés indiquent moins de déprivation.",
'Income Score (rate)':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des valeurs plus élevées indiquent moins de déprivation de revenus. Basé sur les allocations de soutien au revenu, l'allocation de demandeur d'emploi sous condition de ressources, l'allocation d'emploi et de soutien sous condition de ressources, le crédit de retraite, le crédit d'impôt pour le travail et les enfants, l'Universal Credit et les demandeurs d'asile.",
'Employment Score (rate)':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des valeurs plus élevées indiquent moins de déprivation d'emploi. Basé sur les allocataires de l'allocation de demandeur d'emploi, de l'allocation d'emploi et de soutien, de l'allocation d'incapacité, de l'allocation de handicap sévère, de l'allocation d'aidant et les bénéficiaires pertinents de l'Universal Credit.",
'Health Deprivation and Disability Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des scores plus élevés indiquent un risque de décès prématuré plus faible et une meilleure qualité de vie. Dérivé des années de vie potentielle perdues, du ratio comparatif de maladie et d'invalidité, de la morbidité aiguë et des troubles de l'humeur et d'anxiété.",
'Living Environment Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Combine la qualité du logement (état, chauffage central) et l'environnement extérieur (qualité de l'air, sécurité routière). Des scores plus élevés indiquent de meilleurs environnements de vie.",
'Indoors Sub-domain Score':
'Provient des Indices de Déprivation anglais, domaine Environnement de Vie (inversé afin que plus le score est élevé, meilleur est le résultat). Mesure la qualité du parc immobilier : disponibilité du chauffage central, état des logements et normes Decent Homes. Des scores plus élevés indiquent de meilleures conditions de logement.',
'Outdoors Sub-domain Score':
"Provient des Indices de Déprivation anglais, domaine Environnement de Vie (inversé afin que plus le score est élevé, meilleur est le résultat). Mesure la qualité de l'environnement de vie extérieur à travers des indicateurs de qualité de l'air et les victimes d'accidents de la route impliquant des piétons et des cyclistes. Des scores plus élevés indiquent de meilleurs environnements extérieurs.",
'Serious crime per 1k residents (avg/yr)':
"Violences, braquages, cambriolages et possession d'armes pour 1 000 résidents habituels par an dans le LSOA. Utilise les données de criminalité au niveau de la rue de police.uk (2023-2025) et les décomptes de population du Census 2021. Normalise en fonction de la densité de population afin que les zones soient comparables quelle que soit leur taille.",
'Minor crime per 1k residents (avg/yr)':
"Comportements antisociaux, vols à l'étalage, vols de vélos et autres crimes de moindre gravité pour 1 000 résidents habituels par an dans le LSOA. Utilise les données de criminalité au niveau de la rue de police.uk (2023-2025) et les décomptes de population du Census 2021. Normalise en fonction de la densité de population afin que les zones soient comparables quelle que soit leur taille.",
'Serious crime (avg/yr)':
"Somme des violences, braquages, cambriolages et possessions d'armes par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Fournit un indicateur unique de criminalité grave.",
'Minor crime (avg/yr)':
"Somme des comportements antisociaux, vols à l'étalage, vols de vélos et autres crimes de moindre gravité par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Fournit un indicateur unique de criminalité mineure.",
'Violence and sexual offences (avg/yr)':
'Nombre moyen de violences et infractions sexuelles par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les agressions, le harcèlement et les infractions sexuelles.',
'Burglary (avg/yr)':
'Nombre moyen de cambriolages par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les cambriolages résidentiels et commerciaux.',
'Robbery (avg/yr)':
'Nombre moyen de braquages par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Le braquage implique un vol avec usage ou menace de la force.',
'Vehicle crime (avg/yr)':
"Nombre moyen d'incidents de criminalité liés aux véhicules par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut le vol de véhicules et les vols à l'intérieur des véhicules.",
'Anti-social behaviour (avg/yr)':
"Nombre moyen d'incidents de comportement antisocial par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les nuisances, les comportements antisociaux environnementaux et personnels.",
'Criminal damage and arson (avg/yr)':
"Nombre moyen d'incidents de dommages criminels et d'incendie volontaire par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025).",
'Other theft (avg/yr)':
"Nombre moyen d'infractions de « vol divers » par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les vols ne relevant pas des catégories cambriolage, criminalité liée aux véhicules, vol à l'étalage ou vol de vélos.",
'Theft from the person (avg/yr)':
"Nombre moyen d'infractions de vol à la tire par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut le pickpocket et l'arrachage de sac sans violence.",
'Shoplifting (avg/yr)':
"Nombre moyen d'infractions de vol à l'étalage par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025).",
'Bicycle theft (avg/yr)':
"Nombre moyen d'infractions de vol de vélos par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025).",
'Drugs (avg/yr)':
"Nombre moyen d'infractions liées aux drogues par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les infractions de possession et de trafic.",
'Possession of weapons (avg/yr)':
"Nombre moyen d'infractions de possession d'armes par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025).",
'Public order (avg/yr)':
"Nombre moyen d'infractions à l'ordre public par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Inclut les actes causant de la peur, de l'alarme ou de la détresse.",
'Other crime (avg/yr)':
"Nombre moyen d'autres infractions criminelles par an dans le LSOA, provenant des données de criminalité au niveau de la rue de police.uk (2023-2025). Catégorie fourre-tout pour les infractions non classées ailleurs.",
'Median age':
"Provient du Census 2021 (TS007A). Âge médian des résidents habituels dans le LSOA, calculé par interpolation linéaire à partir des effectifs par tranche d'âge de cinq ans. Les zones à population plus jeune ont tendance à être urbaines, universitaires ou à accueillir davantage de familles ; les médianes plus élevées sont typiques des zones rurales et côtières.",
'% White':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Blanc (anglais, gallois, écossais, nord-irlandais, britannique, irlandais, Gitan ou Voyageur irlandais, Rom, ou tout autre origine blanche).",
'% South Asian':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Indien, Pakistanais, Bangladais ou toute autre origine asiatique.",
'% Black':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Noir, Noir britannique, Caribéen ou Africain.",
'% East Asian':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Chinois.",
'% Mixed':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Mixte ou appartenant à plusieurs groupes ethniques (Blanc et Noir caribéen, Blanc et Noir africain, Blanc et Asiatique, ou tout autre fond mixte ou multiple).",
'% Other':
"Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme appartenant à un autre groupe ethnique (Arabe ou tout autre groupe ethnique non couvert par les catégories principales).",
'Winning party':
"Le parti politique qui a obtenu le plus de votes dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024. Basé sur les résultats au scrutin uninominal majoritaire publiés par le Parlement britannique. Les circonscriptions ont été redessinées pour 2024 selon la révision de la Commission des limites de 2023.",
'Voter turnout (%)':
"La proportion de l'électorat inscrit qui a voté de manière valide lors des élections générales britanniques de juillet 2024. Calculée comme le nombre de votes valides divisé par la taille de l'électorat. Une participation plus élevée est généralement corrélée avec des zones plus aisées et des scrutins plus serrés.",
'Majority (%)':
"La différence de voix entre le candidat vainqueur et le second, exprimée en pourcentage du total des votes valides. Une faible majorité indique un siège marginal (compétitif) ; une forte majorité indique un siège sûr. Provient des résultats des élections générales britanniques de juillet 2024 publiés par le Parlement britannique.",
'% Labour':
"Pourcentage des votes valides exprimés pour le Parti travailliste dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024. Comprend les votes de tous les candidats travaillistes.",
'% Conservative':
"Pourcentage des votes valides exprimés pour le Parti conservateur dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'% Liberal Democrat':
"Pourcentage des votes valides exprimés pour les Libéraux-démocrates dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'% Reform UK':
"Pourcentage des votes valides exprimés pour Reform UK dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'% Green':
"Pourcentage des votes valides exprimés pour le Parti vert dans la circonscription couvrant ce code postal, lors des élections générales britanniques de juillet 2024.",
'% Other parties':
"Pourcentage des votes valides exprimés pour des partis autres que Travailliste, Conservateur, Libéral-démocrate, Reform UK et Vert dans la circonscription couvrant ce code postal. Comprend les indépendants, le Président de la Chambre et les partis mineurs.",
'Distance to nearest park (km)':
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.",
'Number of parks within 1km':
'Nombre de parcs publics, jardins, terrains de jeux et espaces de loisirs dont au moins une entrée se trouve dans un rayon de 1km du centroïde du code postal de la propri<72><69>té. Dérivé du jeu de données OS Open Greenspace (Ordnance Survey), utilisant les emplacements des entrées de parcs pour une correspondance de proximité précise.',
'Number of restaurants within 2km':
'Restaurants, cafés et établissements de restauration dans un rayon de 2km du code postal. Source : OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
"Nombre de supermarchés, épiceries et autres commerces alimentaires dans un rayon de 2km du centroïde du code postal de la propriété. Dérivé des données POI d'OpenStreetMap.",
'Noise (dB)':
"Niveau de bruit routier en décibels (Lden, moyenne pondérée sur 24 heures) provenant de la cartographie stratégique du bruit de Defra, 4e cycle (2022). Modélisé à 4m au-dessus du sol sur une grille de 10m. Au-dessus d'environ 55 dB, le bruit est généralement perceptible ; au-dessus d'environ 70 dB, il est considéré comme nocif par l'OMS.",
'Max available download speed (Mbps)':
"Vitesse de téléchargement fixe maximale disponible auprès de n'importe quel fournisseur, provenant d'Ofcom Connected Nations 2025. Représente le maximum théorique, et non les vitesses réellement atteintes. 10 Mbps = basique, 30 = superrapide, 100+ = ultra-rapide, 1000 = gigabit.",
},
de: {
'Property type':
'Aus den HM Land Registry Price Paid-Daten und EPC-Zertifikaten. Freistehend, Doppelhaushälfte, Reihenhaus (umfasst alle Untertypen), Wohnungen/Maisonettes oder Sonstiges (Bungalows, Mobilheime usw.).',
'Leasehold/Freehold':
'Aus den HM Land Registry Price Paid-Daten. Freehold bedeutet, dass Sie das Gebäude und das Grundstück besitzen. Leasehold bedeutet, dass Sie das Gebäude, aber nicht das Grundstück besitzen: Sie haben einen Pachtvertrag vom Freeholder für eine festgelegte Anzahl von Jahren.',
'Last known price':
'Der zuletzt erfasste Verkaufspreis für diese Immobilie aus den HM Land Registry Price Paid-Daten. Umfasst Wohnimmobilienverkäufe in England. Kann Jahre alt sein, wenn die Immobilie nicht kürzlich verkauft wurde.',
'Estimated current price':
'Basiert auf dem letzten Verkaufspreis, angepasst an lokale Preisveränderungen im Laufe der Zeit mithilfe eines Repeat-Sales-Index (erfasst pro Postleitzahlensektor und Immobilientyp). Wenn nach dem Verkauf durchgeführte Renovierungen aus EPC-Aufzeichnungen erkennbar sind, wird ein Renovierungsaufschlag hinzugefügt. Kürzliche Verkäufe liegen nahe am ursprünglichen Preis; ältere Verkäufe werden stärker angepasst.',
'Price per sqm':
'Berechnet durch Division des zuletzt bekannten Verkaufspreises durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Nützlich zum Vergleich des Wertes verschiedener Immobiliengrößen. Nur verfügbar, wenn sowohl Preis- als auch Flächendaten vorhanden sind.',
'Est. price per sqm':
'Berechnet durch Division des inflationsbereinigten geschätzten aktuellen Preises (einschließlich etwaiger Renovierungsaufschläge) durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Bietet einen aktuelleren Preis-pro-Fläche-Vergleich als der historische Verkaufspreis pro sqm.',
'Estimated monthly rent':
'Monatlicher Median-Mietpreis aus den ONS Private Rental Market Summary Statistics (Okt. 2022 Sep. 2023), abgeglichen nach Gemeinde und Zimmeranzahl. Basiert auf Vermietungsdaten der Valuation Office Agency.',
'Total floor area (sqm)':
'Gesamte nutzbare Wohnfläche in Quadratmetern, gemessen während der Bewertung für das Energieausweis-Zertifikat. Umfasst alle Wohnräume, schließt jedoch Garagen, Nebengebäude und Außenbereiche aus.',
'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.',
'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':
'Abgeleitet aus dem TENURE-Feld in den Energieausweis-Daten. Wenn für diese Immobilie ein EPC-Zertifikat das Nutzungsverhältnis als Sozialmiete erfasste, deutet dies darauf hin, dass die Immobilie zum Zeitpunkt dieser Inspektion Gemeinde- oder Wohnungsbaugesellschaftsbestand war. Immobilien, die später verkauft wurden (z. B. über Right to Buy), behalten dieses Merkmal.',
'Current energy rating':
'Die aktuelle Energieeffizienzklasse aus dem Energieausweis-Zertifikat. Reicht von A (am effizientesten) bis G (am wenigsten effizient). Basiert auf dem Energieverbrauch der Immobilie pro Quadratmeter Wohnfläche.',
'Potential energy rating':
'Die potenzielle Energieeffizienzklasse aus dem Energieausweis-Zertifikat, wenn alle im EPC-Bericht empfohlenen kosteneffizienten Verbesserungen durchgeführt würden. Reicht von A (am effizientesten) bis G (am wenigsten effizient).',
'Interior height (m)':
'Durchschnittliche lichte Raumhöhe in Metern, wie während der Energieausweis-Begutachtung erfasst. Berechnet durch Division des gesamten Innenvolumens durch die Gesamtwohnfläche.',
'Distance to nearest train or tube station (km)':
'Luftlinienentfernung in Kilometern vom Postleitzahlenzentrum zur nächsten Bahnstation oder U-Bahn-/Metro-/Straßenbahnhaltestelle.',
'Good+ primary schools within 2km':
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary schools within 2km':
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ primary schools within 5km':
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary schools within 5km':
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Education, Skills and Training Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Umfasst Schulleistungen, Hochschulzugang, Qualifikationen Erwachsener und Englischsprachkenntnisse. Höhere Werte weisen auf geringere Benachteiligung hin.',
'Income Score (rate)':
"Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf geringere Einkommensbenachteiligung hin. Basiert auf Income Support, einkommensbasiertem Jobseeker's Allowance, einkommensbasiertem Employment and Support Allowance, Pension Credit, Working Tax Credit und Child Tax Credit, Universal Credit sowie Asylbewerbern.",
'Employment Score (rate)':
"Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf geringere Beschäftigungsbenachteiligung hin. Basiert auf Empfängern von Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance und relevanten Universal Credit-Empfängern.",
'Health Deprivation and Disability Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf ein geringeres Risiko eines vorzeitigen Todes und eine bessere Lebensqualität hin. Abgeleitet aus verlorenen Lebensjahren, vergleichender Krankheits- und Behinderungsquote, akuter Morbidität sowie Stimmungs- und Angststörungen.',
'Living Environment Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Kombiniert Wohnqualität (Zustand, Zentralheizung) und Außenumgebung (Luftqualität, Verkehrssicherheit). Höhere Werte weisen auf bessere Wohnumgebungen hin.',
'Indoors Sub-domain Score':
'Aus den englischen Deprivationsindizes, Bereich Wohnumgebung (invertiert, sodass höher = besser bedeutet). Misst die Qualität des Wohnungsbestands: Verfügbarkeit von Zentralheizung, Wohnungszustand und Decent Homes-Standards. Höhere Werte weisen auf bessere Wohnbedingungen hin.',
'Outdoors Sub-domain Score':
'Aus den englischen Deprivationsindizes, Bereich Wohnumgebung (invertiert, sodass höher = besser bedeutet). Misst die Qualität der Außenwohnumgebung anhand von Luftqualitätsindikatoren und Straßenverkehrsunfällen mit Fußgängern und Radfahrern. Höhere Werte weisen auf bessere Außenumgebungen hin.',
'Serious crime per 1k residents (avg/yr)':
'Gewalt, Raub, Einbruch und Waffenbesitz pro 1.000 Einwohner pro Jahr im LSOA. Verwendet police.uk-Kriminalitätsdaten auf Straßenebene (20232025) und Census 2021-Bevölkerungszahlen. Normalisiert nach Bevölkerungsdichte, sodass Gebiete unabhängig von ihrer Größe vergleichbar sind.',
'Minor crime per 1k residents (avg/yr)':
'Asoziales Verhalten, Ladendiebstahl, Fahrraddiebstahl und andere weniger schwere Straftaten pro 1.000 Einwohner pro Jahr im LSOA. Verwendet police.uk-Kriminalitätsdaten auf Straßenebene (20232025) und Census 2021-Bevölkerungszahlen. Normalisiert nach Bevölkerungsdichte, sodass Gebiete unabhängig von ihrer Größe vergleichbar sind.',
'Serious crime (avg/yr)':
'Summe aus Gewalt, Raub, Einbruch und Waffenbesitz pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Bietet einen einzelnen Indikator für schwere Kriminalität.',
'Minor crime (avg/yr)':
'Summe aus asozialem Verhalten, Ladendiebstahl, Fahrraddiebstahl und anderen weniger schweren Straftaten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Bietet einen einzelnen Indikator für leichte Kriminalität.',
'Violence and sexual offences (avg/yr)':
'Durchschnittliche Anzahl von Gewalt- und Sexualdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst Körperverletzung, Belästigung und Sexualdelikte.',
'Burglary (avg/yr)':
'Durchschnittliche Anzahl von Einbruchsdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst Wohnungs- und Gewerbeeinbrüche.',
'Robbery (avg/yr)':
'Durchschnittliche Anzahl von Raubdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Raub umfasst Diebstahl unter Anwendung von Gewalt oder Gewaltandrohung.',
'Vehicle crime (avg/yr)':
'Durchschnittliche Anzahl von Fahrzeugkriminalität pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst Diebstahl von und aus Fahrzeugen.',
'Anti-social behaviour (avg/yr)':
'Durchschnittliche Anzahl von Vorfällen asozialen Verhaltens pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst störendes, umweltbezogenes und persönlich asoziales Verhalten.',
'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 (20232025).',
'Other theft (avg/yr)':
'Durchschnittliche Anzahl von „sonstigen Diebstählen" pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). 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 (20232025). Umfasst Taschendiebstahl und Handtaschenraub ohne Gewaltanwendung.',
'Shoplifting (avg/yr)':
'Durchschnittliche Anzahl von Ladendiebstahlsdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025).',
'Bicycle theft (avg/yr)':
'Durchschnittliche Anzahl von Fahrraddiebstahlsdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025).',
'Drugs (avg/yr)':
'Durchschnittliche Anzahl von Drogendelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst Besitz- und Handelsdelikte.',
'Possession of weapons (avg/yr)':
'Durchschnittliche Anzahl von Waffenbesitzdelikten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025).',
'Public order (avg/yr)':
'Durchschnittliche Anzahl von Delikten gegen die öffentliche Ordnung pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Umfasst das Verursachen von Furcht, Alarm oder Bedrängnis.',
'Other crime (avg/yr)':
'Durchschnittliche Anzahl sonstiger Straftaten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene (20232025). Eine Sammelkategorie für Straftaten, die nicht anderweitig eingestuft sind.',
'Median age':
'Aus dem Census 2021 (TS007A). Medianalter der ortsansässigen Bevölkerung im LSOA, berechnet durch lineare Interpolation aus Fünfjahres-Altersband-Zählungen. Gebiete mit jüngerer Bevölkerung sind tendenziell städtisch, Universitätsstädte oder haben mehr Familien; höhere Mediane sind typisch für ländliche und Küstengebiete.',
'% White':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Weiß identifiziert (Englisch, Walisisch, Schottisch, Nordirisch, Britisch, Irisch, Sinti und Roma, Roma oder sonstiger weißer Hintergrund).',
'% South Asian':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Indisch, Pakistanisch, Bangladeschisch oder mit sonstigem asiatischen Hintergrund identifiziert.',
'% Black':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Schwarz, Schwarz-Britisch, Karibisch oder Afrikanisch identifiziert.',
'% East Asian':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Chinesisch identifiziert.',
'% Mixed':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als gemischt oder mit mehreren ethnischen Zugehörigkeiten identifiziert (Weiß und Schwarzkaribisch, Weiß und Schwarzafrikanisch, Weiß und Asiatisch oder sonstiger gemischter Hintergrund).',
'% Other':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als einer anderen ethnischen Gruppe zugehörig identifiziert (Arabisch oder eine andere ethnische Gruppe, die nicht von den Hauptkategorien abgedeckt wird).',
'Winning party':
'Die politische Partei, die im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024 die meisten Stimmen erhalten hat. Basierend auf den Ergebnissen des Mehrheitswahlrechts, veröffentlicht vom britischen Parlament. Die Wahlkreise wurden für 2024 nach der Überprüfung der Boundary Commission 2023 neu eingeteilt.',
'Voter turnout (%)':
'Der Anteil der registrierten Wahlberechtigten, die bei der britischen Parlamentswahl im Juli 2024 eine gültige Stimme abgegeben haben. Berechnet als gültige Stimmen geteilt durch die Größe der Wählerschaft. Eine höhere Wahlbeteiligung korreliert im Allgemeinen mit wohlhabenderen Gebieten und knapperen Ergebnissen.',
'Majority (%)':
'Die Stimmendifferenz zwischen dem Gewinner und dem Zweitplatzierten, ausgedrückt als Prozentsatz der gesamten gültigen Stimmen. Eine kleine Mehrheit weist auf einen umkämpften Wahlkreis hin; eine große Mehrheit auf einen sicheren Sitz. Aus den Ergebnissen der britischen Parlamentswahl vom Juli 2024, veröffentlicht vom britischen Parlament.',
'% Labour':
'Prozentsatz der gültigen Stimmen für die Labour Party im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
'% Conservative':
'Prozentsatz der gültigen Stimmen für die Conservative Party im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
'% Liberal Democrat':
'Prozentsatz der gültigen Stimmen für die Liberal Democrats im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
'% Reform UK':
'Prozentsatz der gültigen Stimmen für Reform UK im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
'% Green':
'Prozentsatz der gültigen Stimmen für die Green Party im Wahlkreis dieser Postleitzahl bei der britischen Parlamentswahl im Juli 2024.',
'% Other parties':
'Prozentsatz der gültigen Stimmen für Parteien außer Labour, Conservative, Liberal Democrat, Reform UK und Green im Wahlkreis. Umfasst Unabhängige, den Speaker und kleinere Parteien.',
'Distance to nearest park (km)':
'Luftlinienentfernung in Kilometern vom Postleitzahlenzentrum zum nächsten Parkeingang. Umfasst öffentliche Parks, Gärten, Sportplätze und Spielbereiche. Verwendet Zugangspunktstandorte aus dem OS Open Greenspace-Datensatz, sodass Immobilien an der Grenze eines großen Parks korrekt eine kurze Entfernung anzeigen.',
'Number of parks within 1km':
'Anzahl öffentlicher Parks, Gärten, Sportplätze und Spielbereiche mit mindestens einem Eingang innerhalb eines 1-km-Radius um den Postleitzahlenmittelpunkt der Immobilie. Abgeleitet aus dem OS Open Greenspace-Datensatz (Ordnance Survey) unter Verwendung von Parkeingangsstandorten für genaues Abstandsmatching.',
'Number of restaurants within 2km':
'Restaurants, Cafés und Gastronomiebetriebe innerhalb von 2 km vom Postleitzahlenzentrum. Bezogen aus OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
'Anzahl von Supermärkten, Lebensmittelläden und anderen Lebensmittelgeschäften innerhalb eines 2-km-Radius um den Postleitzahlenmittelpunkt der Immobilie. Abgeleitet aus OpenStreetMap-POI-Daten.',
'Noise (dB)':
'Straßenlärmpegel in Dezibel (Lden, ein 24-Stunden-gewichteter Durchschnitt) aus Defras Strategic Noise Mapping Round 4 (2022). Modelliert in 4 m Höhe über dem Boden auf einem 10-m-Raster. Über ~55 dB ist in der Regel wahrnehmbar; über ~70 dB gilt laut WHO als gesundheitsschädlich.',
'Max available download speed (Mbps)':
'Maximale verfügbare Festnetz-Download-Geschwindigkeit von einem beliebigen Anbieter, aus Ofcom Connected Nations 2025. Gibt die theoretische Höchstgeschwindigkeit an, keine tatsächlich erreichten Geschwindigkeiten. 10 Mbps = Basis, 30 = Superfast, 100+ = Ultrafast, 1000 = Gigabit.',
},
zh: {
'Property type':
'来自英国土地注册局价格数据和EPC证书。包括独立式、半独立式、联排式含所有联排子类型、公寓/复式公寓,或其他类型(平房、移动式住宅等)。',
'Leasehold/Freehold':
'来自英国土地注册局价格数据。Freehold永久产权意味着您拥有建筑物及其所在土地。Leasehold租赁产权意味着您拥有建筑物但不拥有土地您从永久产权人处获得一定年限的租约。',
'Last known price':
'来自英国土地注册局价格数据中该房产最近一次记录的成交价格。涵盖英格兰地区的住宅销售。若该房产近期未出售,数据可能已有数年之久。',
'Estimated current price':
'基于最后一次成交价格使用重复销售指数按邮政编码区段和房产类型追踪调整当地房价随时间的变化。若EPC记录显示售后有改造记录则会增加装修溢价。近期销售与原价接近较早的销售调整幅度更大。',
'Price per sqm':
'用最后已知成交价除以EPC证书中的总建筑面积计算得出。便于比较不同面积房产的价值。仅在价格和面积数据均存在时才可用。',
'Est. price per sqm':
'用经通胀调整的估算当前价格含装修溢价除以EPC证书中的总建筑面积计算得出。与历史成交价格每平方米相比提供更为最新的单位面积价格对比。',
'Estimated monthly rent':
'来自ONS私人租赁市场摘要统计2022年10月至2023年9月的月租金中位数按地方政府和卧室数量匹配。基于估价署租赁数据。',
'Total floor area (sqm)':
'在能源性能证书EPC评估期间测量的总可用建筑面积平方米。包括所有可居住房间但不含车库、附属建筑和外部区域。',
'Number of bedrooms & living rooms':
'EPC中记录的可居住房间总数卧室加客厅。厨房和浴室通常不计入除非面积足够大可算作可居住房间。',
'Construction year':
'根据EPC中的建造年代段例如"1930-1949")取中间值推算。对于年代段跨越数十年的老建筑,精度较低。',
'Date of last transaction':
'来自英国土地注册局价格数据中该房产最近一次成交的记录日期。数据中以日期时间格式存储;在筛选和图表中转换为小数年份。',
'Former council house':
'来自EPC数据中的TENURE字段。若该房产的任何一份EPC证书将产权记录为社会租赁则表明该房产在该次评估时为政府或住房协会存量房。通过Right to Buy等方式出售后的房产仍保留此标记。',
'Current energy rating':
'来自EPC的当前能源效率等级。从A最高效到G最低效。基于每平方米建筑面积的能源使用量。',
'Potential energy rating':
'若实施EPC报告中建议的所有具有成本效益的改进措施后该房产的潜在能源效率等级。从A最高效到G最低效。',
'Interior height (m)':
'EPC评估期间记录的平均室内净高。通过将室内总容积除以总建筑面积计算得出。',
'Distance to nearest train or tube station (km)':
'从邮政编码到最近铁路站或地铁/城铁/轻轨站的直线距离km。',
'Good+ primary schools within 2km':
'2km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'Good+ secondary schools within 2km':
'2km范围内Ofsted评级为"Good"或"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Good+ primary schools within 5km':
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'Good+ secondary schools within 5km':
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Education, Skills and Training Score':
'来自英格兰剥夺指数(取反后越高越好)。涵盖学校成绩、高等教育入学率、成人学历和英语水平。分数越高表示剥夺程度越低。',
'Income Score (rate)':
'来自英格兰剥夺指数(取反后越高越好)。数值越高表示收入剥夺程度越低。基于收入支持、基于收入的求职者津贴、基于收入的就业与支持津贴、养老金补贴、工作税收抵免和子女税收抵免、普惠信用以及寻求庇护者等数据。',
'Employment Score (rate)':
'来自英格兰剥夺指数(取反后越高越好)。数值越高表示就业剥夺程度越低。基于求职者津贴、就业与支持津贴、丧失劳动能力津贴、严重残疾津贴、护理者津贴申领者及相关普惠信用申领者等数据。',
'Health Deprivation and Disability Score':
'来自英格兰剥夺指数(取反后越高越好)。分数越高表示过早死亡风险越低、生活质量越好。来源于潜在寿命损失年、比较疾病和残疾率、急性发病率以及情绪和焦虑障碍等指标。',
'Living Environment Score':
'来自英格兰剥夺指数(取反后越高越好)。综合住房质量(状况、中央供暖)和室外环境(空气质量、道路安全)。分数越高表示居住环境越好。',
'Indoors Sub-domain Score':
'来自英格兰剥夺指数的居住环境领域取反后越高越好。衡量住房存量质量中央供暖覆盖率、住房状况以及Decent Homes标准。分数越高表示住房条件越好。',
'Outdoors Sub-domain Score':
'来自英格兰剥夺指数的居住环境领域(取反后越高越好)。通过空气质量指标以及涉及行人和骑行者的道路交通事故伤亡人数衡量室外生活环境质量。分数越高表示室外环境越好。',
'Serious crime per 1k residents (avg/yr)':
'LSOA内每1,000名常住居民每年发生的暴力、抢劫、入室盗窃和持有武器犯罪数量。使用police.uk街道级犯罪数据2023-2025年和Census 2021人口数据。按人口密度标准化便于不同规模地区之间的比较。',
'Minor crime per 1k residents (avg/yr)':
'LSOA内每1,000名常住居民每年发生的反社会行为、商店行窃、自行车盗窃及其他较轻微犯罪数量。使用police.uk街道级犯罪数据2023-2025年和Census 2021人口数据。按人口密度标准化便于不同规模地区之间的比较。',
'Serious crime (avg/yr)':
'来自police.uk街道级犯罪数据2023-2025年的LSOA内每年暴力、抢劫、入室盗窃和持有武器犯罪总和。提供单一的严重犯罪指标。',
'Minor crime (avg/yr)':
'来自police.uk街道级犯罪数据2023-2025年的LSOA内每年反社会行为、商店行窃、自行车盗窃及其他较轻微犯罪总和。提供单一的轻微犯罪指标。',
'Violence and sexual offences (avg/yr)':
'LSOA内每年暴力和性犯罪的平均数量来自police.uk街道级犯罪数据2023-2025年。包括攻击、骚扰和性犯罪。',
'Burglary (avg/yr)':
'LSOA内每年入室盗窃的平均数量来自police.uk街道级犯罪数据2023-2025年。包括住宅和商业入室盗窃。',
'Robbery (avg/yr)':
'LSOA内每年抢劫案的平均数量来自police.uk街道级犯罪数据2023-2025年。抢劫涉及以暴力或威胁手段实施的盗窃。',
'Vehicle crime (avg/yr)':
'LSOA内每年车辆犯罪事件的平均数量来自police.uk街道级犯罪数据2023-2025年。包括盗窃车辆及从车辆内盗窃。',
'Anti-social behaviour (avg/yr)':
'LSOA内每年反社会行为事件的平均数量来自police.uk街道级犯罪数据2023-2025年。包括滋扰、环境和个人反社会行为。',
'Criminal damage and arson (avg/yr)':
'LSOA内每年刑事损毁和纵火事件的平均数量来自police.uk街道级犯罪数据2023-2025年。',
'Other theft (avg/yr)':
'LSOA内每年"其他盗窃"案的平均数量来自police.uk街道级犯罪数据2023-2025年。包括未被归类为入室盗窃、车辆犯罪、商店行窃或自行车盗窃的盗窃行为。',
'Theft from the person (avg/yr)':
'LSOA内每年针对人身盗窃案的平均数量来自police.uk街道级犯罪数据2023-2025年。包括扒窃和未使用暴力的抢包行为。',
'Shoplifting (avg/yr)':
'LSOA内每年商店行窃案的平均数量来自police.uk街道级犯罪数据2023-2025年。',
'Bicycle theft (avg/yr)':
'LSOA内每年自行车盗窃案的平均数量来自police.uk街道级犯罪数据2023-2025年。',
'Drugs (avg/yr)':
'LSOA内每年毒品犯罪的平均数量来自police.uk街道级犯罪数据2023-2025年。包括持有和贩运毒品犯罪。',
'Possession of weapons (avg/yr)':
'LSOA内每年持有武器犯罪的平均数量来自police.uk街道级犯罪数据2023-2025年。',
'Public order (avg/yr)':
'LSOA内每年公共秩序违法行为的平均数量来自police.uk街道级犯罪数据2023-2025年。包括引起他人恐惧、惊扰或困扰的行为。',
'Other crime (avg/yr)':
'LSOA内每年其他犯罪的平均数量来自police.uk街道级犯罪数据2023-2025年。此类别涵盖未在其他分类中列出的犯罪行为。',
'Median age':
'来自2021年CensusTS007A。通过对五岁年龄段人口数进行线性插值计算得出的LSOA常住居民年龄中位数。年轻人口集中的地区往往是城市、大学城或家庭聚居地年龄中位数较高的地区多见于农村和沿海地区。',
'% White':
'来自2021年Census。地方政府人口中认同为白人英格兰人、威尔士人、苏格兰人、北爱尔兰人、英国人、爱尔兰人、吉普赛人或爱尔兰旅行者、罗姆人或其他白人背景的百分比。',
'% South Asian':
'来自2021年Census。地方政府人口中认同为印度人、巴基斯坦人、孟加拉国人或其他亚洲背景的百分比。',
'% Black': '来自2021年Census。地方政府人口中认同为黑人、英国黑人、加勒比人或非洲人的百分比。',
'% East Asian': '来自2021年Census。地方政府人口中认同为华人的百分比。',
'% Mixed':
'来自2021年Census。地方政府人口中认同为<E5908C><E4B8BA>血或多种族群体白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔或其他混血或多种族背景的百分比。',
'% Other':
'来自2021年Census。地方政府人口中认同为其他族裔群体阿拉伯人或其他未被主要类别涵盖的族裔的百分比。',
'Winning party':
'在2024年7月英国大选中该邮编所属选区得票最多的政党。基于英国议会公布的简单多数制选举结果。选区根据2023年边界委员会审查进行了重新划分。',
'Voter turnout (%)':
'2024年7月英国大选中投出有效选票的登记选民比例。计算方式为有效票数除以选民总数。较高的投票率通常与较富裕地区和竞争更激烈的选举相关。',
'Majority (%)':
'获胜候选人与第二名之间的票数差距以有效投票总数的百分比表示。小的多数票表示边缘选区竞争激烈大的多数票表示安全席位。数据来自英国议会公布的2024年7月大选结果。',
'% Labour':
'2024年7月英国大选中该邮编所属选区投给工党的有效选票百分比。包括所有工党候选人的选票。',
'% Conservative':
'2024年7月英国大选中该邮编所属选区投给保守党的有效选票百分比。',
'% Liberal Democrat':
'2024年7月英国大选中该邮编所属选区投给自由民主党的有效选票百分比。',
'% Reform UK':
'2024年7月英国大选中该邮编所属选区投给英国改革党的有效选票百分比。',
'% Green':
'2024年7月英国大选中该邮编所属选区投给绿党的有效选票百分比。',
'% Other parties':
'该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。',
'Distance to nearest park (km)':
'从邮政编码到最近公园入口的直线距离km。涵盖公共公园、花园、运动<E8BF90><E58AA8><EFBFBD>和游乐场地。使用OS Open Greenspace数据集中的出入口位置因此紧邻大型公园的房产可正确显示较短距离。',
'Number of parks within 1km':
'以房产邮政编码中心点为圆心1km半径内至少有一个入口的公共公园、花园、运动场和游乐场地数量。来源于OS Open Greenspace数据集英国地形测量局使用公园入口位置进行精确近距离匹配。',
'Number of restaurants within 2km':
'邮政编码2km范围内的餐厅、咖啡馆和餐饮场所数量。来源于OpenStreetMap。',
'Number of grocery shops and supermarkets within 2km':
'以房产邮政编码中心点为圆心2km半径内的超市、便利店和其他杂货店数量。来源于OpenStreetMap POI数据。',
'Noise (dB)':
'来自Defra战略噪声图第4轮2022年的道路噪声水平单位为分贝Lden24小时加权平均值。在地面以上4m、10m网格间距处建模。一般而言超过约55 dB可明显感知超过约70 dB被世卫组织认定为有害。',
'Max available download speed (Mbps)':
'来自Ofcom Connected Nations 2025的任意运营商可提供的最大固定宽带下载速度。代表理论最大值而非实际达到的速度。10 Mbps为基础级30为超快级100+为极速级1000为千兆级。',
},
hu: {
'Property type':
'Az HM Land Registry Price Paid adatokból és EPC tanúsítványokból. Különálló, Ikerház, Sorház (minden sorház altípust tartalmaz), Lakás/Maisonette, vagy Egyéb (bungaló, mobilház stb.).',
'Leasehold/Freehold':
'Az HM Land Registry Price Paid adatokból. A Freehold azt jelenti, hogy az épület és a telek is az Ön tulajdona. A Leasehold azt jelenti, hogy az épület az Ön tulajdona, de a telek nem: a telektulajdonostól meghatározott számú évre szóló bérleti jogot kapott.',
'Last known price':
'Az ingatlan utolsó rögzített adásvételi ára az HM Land Registry Price Paid adatokból. Az angliai lakóingatlan-értékesítésekre vonatkozik. Lehet, hogy évekkel ezelőtti adat, ha az ingatlan nem kelt el a közelmúltban.',
'Estimated current price':
'Az utolsó adásvételi áron alapul, amelyet az idő múlásával bekövetkezett helyi árváltozásokhoz igazítottak egy ismételt értékesítési index segítségével (irányítószám-szektor és ingatlan típusa szerint nyomon követve). Ha az EPC-adatokból az értékesítés utáni felújítás észlelhető, felújítási prémium kerül hozzáadásra. A közelmúltbeli adásvételek közel lesznek az eredeti árhoz; a régebbi adásvételeket jobban korrigálják.',
'Price per sqm':
'Az utolsó ismert adásvételi árat az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Hasznos a különböző méretű ingatlanok értékének összehasonlításához. Csak akkor elérhető, ha mind az ár, mind az alapterület adatai rendelkezésre állnak.',
'Est. price per sqm':
'Az inflációval korrigált becsült aktuális árat (beleértve az esetleges felújítási prémiumot) az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Naprakészebb ár/terület összehasonlítást nyújt, mint a korábbi adásvételi ár per sqm.',
'Estimated monthly rent':
'Az ONS Magánbérleti Piaci Összefoglaló Statisztikákból (2022. október 2023. szeptember) származó medián havi bérleti díj, helyi hatóság és hálószobák száma szerint párosítva. A Valuation Office Agency bérbeadási adatain alapul.',
'Total floor area (sqm)':
'Az Energy Performance Certificate felmérése során mért teljes hasznos alapterület négyzetméterben. Tartalmazza az összes lakható helyiséget, de kizárja a garázsokat, melléképületeket és külső területeket.',
'Number of bedrooms & living rooms':
'Az Energy Performance Certificate-ben rögzített összes lakható helyiség száma (hálószobák és nappali szobák összege). A konyhák és fürdőszobák jellemzően nem számítanak bele, kivéve, ha elég nagyok ahhoz, hogy lakható helyiségnek minősüljenek.',
'Construction year':
"Az EPC-ben szereplő építési korszak alapján (pl. '19301949') a középértékkel becsülve. Régebbi épületeknél kevésbé pontos, ahol a korcsoport több évtizedet ölel fel.",
'Date of last transaction':
'Az ingatlan legutóbbi rögzített adásvételének dátuma az HM Land Registry Price Paid adatokból. Az adatokban dátum/idő formátumban tárolódik; szűréshez és diagramokhoz törtéves formátumra konvertálva.',
'Former council house':
'Az Energy Performance Certificate adatok TENURE mezőjéből származtatva. Ha az ingatlan bármely EPC tanúsítványa szociális bérlakásként rögzítette a bérleti jogviszonyt, ez azt jelzi, hogy az ingatlan az adott ellenőrzés idején önkormányzati vagy lakásszövetkezeti állomány volt. Azok az ingatlanok, amelyeket később értékesítettek (pl. Right to Buy útján), megőrzik ezt a jelzést.',
'Current energy rating':
'Az Energy Performance Certificate aktuális energiahatékonysági besorolása. A-tól (leghatékonyabb) G-ig (legkevésbé hatékony) terjed. Az ingatlan alapterületre vetített energiafelhasználásán alapul.',
'Potential energy rating':
'Az Energy Performance Certificate potenciális energiahatékonysági besorolása, amennyiben az EPC-jelentésben ajánlott összes költséghatékony fejlesztést elvégeznék. A-tól (leghatékonyabb) G-ig (legkevésbé hatékony) terjed.',
'Interior height (m)':
'Az Energy Performance Certificate felmérése során rögzített átlagos belső padló-mennyezet magasság méterben. A teljes belső térfogatot osztják a teljes alapterülettel.',
'Distance to nearest train or tube station (km)':
'Légvonalbeli távolság kilométerben az irányítószámtól a legközelebbi vasút- vagy metró-/városi vasút-/villamosmegállóig.',
'Good+ primary schools within 2km':
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ secondary schools within 2km':
'2 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ primary schools within 5km':
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ secondary schools within 5km':
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Education, Skills and Training Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). Az iskolai teljesítményt, a felsőoktatásba való bejutást, a felnőttkori képesítéseket és az angol nyelvi jártasságot foglalja magában. A magasabb pontszámok kisebb mértékű nélkülözést jeleznek.',
'Income Score (rate)':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb értékek kisebb mértékű jövedelmi nélkülözést jeleznek. A jövedelempótló támogatás, jövedelemalapú Munkaügyi Segély, jövedelemalapú Foglalkoztatási és Támogatási Segély, Nyugdíjkiegészítés, Munkavállalói és Gyermekadókedvezmény, Univerzális Hitel és menedékkérők alapján.',
'Employment Score (rate)':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb értékek kisebb mértékű foglalkoztatási nélkülözést jeleznek. A Munkaügyi Segély, Foglalkoztatási és Támogatási Segély, Munkaképtelenségi Juttatás, Súlyos Rokkantsági Pótlék, Gondozói Juttatás igénylői és a vonatkozó Univerzális Hitel igénylői alapján.',
'Health Deprivation and Disability Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb pontszámok alacsonyabb korai halálozási kockázatot és jobb életminőséget jeleznek. Az elveszített potenciális életévekből, a komparatív betegségi és rokkantsági arányból, az akut morbiditásból, valamint a hangulati és szorongásos zavarokból vezethető le.',
'Living Environment Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). Ötvözi a lakásminőséget (állapot, gázfűtés) és a külső környezetet (levegőminőség, közlekedésbiztonság). A magasabb pontszámok jobb lakókörnyezetet jeleznek.',
'Indoors Sub-domain Score':
'Az Angol Nélkülözési Indexek Lakókörnyezet tartományából (megfordítva, így magasabb = jobb). A lakásállomány minőségét méri: gázfűtés rendelkezésre állása, lakásállapot és Decent Homes szabványok. A magasabb pontszámok jobb lakáskörülményeket jeleznek.',
'Outdoors Sub-domain Score':
'Az Angol Nélkülözési Indexek Lakókörnyezet tartományából (megfordítva, így magasabb = jobb). A külső lakókörnyezet minőségét méri a levegőminőségi mutatók és a gyalogosokat, kerékpárosokat érintő közúti közlekedési baleseti áldozatok alapján. A magasabb pontszámok jobb külső környezetet jeleznek.',
'Serious crime per 1k residents (avg/yr)':
'Erőszakos bűncselekmények, rablás, betörés és fegyverbirtoklás 1 000 szokásos lakóra vetítve évente az LSOA-ban. A police.uk utcai szintű bűnügyi adatait (20232025) és a Census 2021 népességszámait használja. Normalizálja a népsűrűséget, így a területek mérettől függetlenül összehasonlíthatók.',
'Minor crime per 1k residents (avg/yr)':
'Antiszociális magatartás, boltlopás, kerékpárlopás és egyéb kisebb súlyosságú bűncselekmények 1 000 szokásos lakóra vetítve évente az LSOA-ban. A police.uk utcai szintű bűnügyi adatait (20232025) és a Census 2021 népességszámait használja. Normalizálja a népsűrűséget, így a területek mérettől függetlenül összehasonlíthatók.',
'Serious crime (avg/yr)':
'Az erőszakos bűncselekmények, rablás, betörés és fegyverbirtoklás éves összege az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Egyetlen súlyos bűnözési mutatót ad.',
'Minor crime (avg/yr)':
'Az antiszociális magatartás, boltlopás, kerékpárlopás és egyéb kisebb súlyosságú bűncselekmények éves összege az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Egyetlen kisebb bűnözési mutatót ad.',
'Violence and sexual offences (avg/yr)':
'Az erőszakos és szexuális bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a testi sértést, zaklatást és szexuális bűncselekményeket.',
'Burglary (avg/yr)':
'A betörések átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a lakó- és kereskedelmi célú betöréseket.',
'Robbery (avg/yr)':
'A rablások átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). A rablás erővel vagy erőszakkal fenyegetéssel járó lopást jelent.',
'Vehicle crime (avg/yr)':
'A járművel kapcsolatos bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a járművek ellopását és a járművekből való lopást.',
'Anti-social behaviour (avg/yr)':
'Az antiszociális magatartási esetek átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a zavarást, környezeti és személyes antiszociális magatartást.',
'Criminal damage and arson (avg/yr)':
'A rongálás és gyújtogatás átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025).',
'Other theft (avg/yr)':
"Az 'egyéb lopás' kategóriájú bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a betörés, járműves bűncselekmény, boltlopás vagy kerékpárlopás alá nem sorolt lopásokat.",
'Theft from the person (avg/yr)':
'A személytől való lopás átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a zsebtolvajlást és erő nélküli táskavágást.',
'Shoplifting (avg/yr)':
'A boltlopások átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025).',
'Bicycle theft (avg/yr)':
'A kerékpárlopások átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025).',
'Drugs (avg/yr)':
'A kábítószer-bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a birtoklási és terjesztési bűncselekményeket.',
'Possession of weapons (avg/yr)':
'A fegyverbirtoklási bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025).',
'Public order (avg/yr)':
'A közrend elleni bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Magában foglalja a félelemkeltést, riasztást vagy szorongást okozó cselekményeket.',
'Other crime (avg/yr)':
'Az egyéb bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból (20232025). Gyűjtőkategória azoknak a bűncselekményeknek, amelyek máshol nem kerülnek besorolásra.',
'Median age':
'A 2021-es Census alapján (TS007A). Az LSOA szokásos lakóinak medián életkora, ötéves korcsoport-számlálásokból lineáris interpolációval számítva. A fiatalabb népességű területek jellemzően városiak, egyetemi városok vagy több családot vonzanak; az idősebb medián értékek jellemzően vidéki és tengerparti területekre jellemzők.',
'% White':
'A 2021-es Census alapján. A helyi hatóság területén fehérként (angol, walesi, skót, észak-ír, brit, ír, cigány vagy ír vándor, roma, vagy bármely más fehér háttér) azonosított népesség százaléka.',
'% South Asian':
'A 2021-es Census alapján. A helyi hatóság területén indiai, pakisztáni, bangladesi vagy bármely más ázsiai háttérként azonosított népesség százaléka.',
'% Black':
'A 2021-es Census alapján. A helyi hatóság területén fekete, brit fekete, karibi vagy afrikai háttérként azonosított népesség százaléka.',
'% East Asian':
'A 2021-es Census alapján. A helyi hatóság területén kínaiként azonosított népesség százaléka.',
'% Mixed':
'A 2021-es Census alapján. A helyi hatóság területén vegyes vagy többes etnikai csoportként (fehér és fekete karibi, fehér és fekete afrikai, fehér és ázsiai, vagy bármely más vegyes vagy többes háttér) azonosított népesség százaléka.',
'% Other':
'A 2021-es Census alapján. A helyi hatóság területén egyéb etnikai csoportként (arab vagy bármely más, a főkategóriák által nem lefedett etnikai csoport) azonosított népesség százaléka.',
'Winning party':
'Az a politikai párt, amely a legtöbb szavazatot kapta az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson. Az Egyesült Királyság Parlamentje által közzétett, egyéni választókerületi rendszer szerinti eredmények alapján. A választókerületeket a 2023-as Határbizottsági felülvizsgálat alapján alakították át 2024-re.',
'Voter turnout (%)':
'A regisztrált szavazók azon aránya, akik érvényes szavazatot adtak le a 2024. júliusi brit parlamenti választáson. Az érvényes szavazatok száma osztva a választói névjegyzékben szereplők számával. A magasabb részvétel általában a tehetősebb területekkel és a szorosabb versenyekkel korrelál.',
'Majority (%)':
'A győztes jelölt és a második helyezett közötti szavazatkülönbség, az összes érvényes szavazat százalékában kifejezve. Kis többség billegő körzetre utal (versenyképes); nagy többség biztos körzetre. A 2024. júliusi brit parlamenti választás eredményeiből, amelyeket az Egyesült Királyság Parlamentje tett közzé.',
'% Labour':
'Az érvényes szavazatok százaléka, amelyeket a Munkáspártra adtak le az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson.',
'% Conservative':
'Az érvényes szavazatok százaléka, amelyeket a Konzervatív Pártra adtak le az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson.',
'% Liberal Democrat':
'Az érvényes szavazatok százaléka, amelyeket a Liberális Demokratákra adtak le az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson.',
'% Reform UK':
'Az érvényes szavazatok százaléka, amelyeket a Reform UK-ra adtak le az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson.',
'% Green':
'Az érvényes szavazatok százaléka, amelyeket a Zöld Pártra adtak le az adott irányítószámhoz tartozó választókerületben a 2024. júliusi brit parlamenti választáson.',
'% Other parties':
'Az érvényes szavazatok százaléka, amelyeket a Munkáspárton, Konzervatívokon, Liberális Demokratákon, Reform UK-n és Zöldeken kívüli pártokra adtak le. Tartalmazza a függetleneket, a Házelnököt és a kisebb pártokat.',
'Distance to nearest park (km)':
'Légvonalbeli távolság kilométerben az irányítószámtól a legközelebbi park bejáratáig. Magában foglalja a közparkokat, kerteket, játszótereket és szabadidős területeket. Az OS Open Greenspace adatkészlet hozzáférési pont helyszíneit használja, így a nagy park szomszédságában lévő ingatlanok helyesen rövid távolságot mutatnak.',
'Number of parks within 1km':
'A közparkok, kertek, játszóterek és szabadidős területek száma, amelyeknek legalább egy bejárata van az ingatlan irányítószám centroidjától számított 1 km-es körzetben. Az OS Open Greenspace adatkészletből (Ordnance Survey) származik, park bejárati helyszíneket használva a pontos közelségi egyeztetéshez.',
'Number of restaurants within 2km':
'Az ingatlan irányítószámjától 2 km-en belüli éttermek, kávézók és vendéglátóhelyek. Forrás: OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
'Az ingatlan irányítószám centroidjától számított 2 km-es körzetben lévő szupermarketek, kisboltok és egyéb élelmiszerboltok száma. Az OpenStreetMap POI-adatokból származtatva.',
'Noise (dB)':
'Közúti zajszint decibel (Lden, 24 órás súlyozott átlag) értékben, a Defra Stratégiai Zajtérképezés 4. fordulójából (2022). 4 m magasságban, 10 m-es rácson modellezve. ~55 dB felett általában érzékelhető; ~70 dB felett az WHO károsnak minősíti.',
'Max available download speed (Mbps)':
'Bármely szolgáltatótól elérhető maximális rögzített szélessávú letöltési sebesség, az Ofcom Connected Nations 2025 adataiból. Az elméleti maximumot jelöli, nem a valós sebességet. 10 Mbps = alapszintű, 30 = szupergyors, 100+ = ultragyors, 1000 = gigabites.',
},
};

View file

@ -7,11 +7,11 @@ import hu from './locales/hu';
import zh from './locales/zh';
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English', flag: '\uD83C\uDDEC\uD83C\uDDE7' },
{ code: 'fr', label: 'Fran\u00E7ais', flag: '\uD83C\uDDEB\uD83C\uDDF7' },
{ code: 'de', label: 'Deutsch', flag: '\uD83C\uDDE9\uD83C\uDDEA' },
{ code: 'hu', label: 'Magyar', flag: '\uD83C\uDDED\uD83C\uDDFA' },
{ code: 'zh', label: '\u4E2D\u6587', flag: '\uD83C\uDDE8\uD83C\uDDF3' },
{ code: 'en', label: 'English', flag: '\uD83C\uDDEC\uD83C\uDDE7' },
{ code: 'fr', label: 'Fran\u00E7ais', flag: '\uD83C\uDDEB\uD83C\uDDF7' },
{ code: 'de', label: 'Deutsch', flag: '\uD83C\uDDE9\uD83C\uDDEA' },
{ code: 'hu', label: 'Magyar', flag: '\uD83C\uDDED\uD83C\uDDFA' },
{ code: 'zh', label: '\u4E2D\u6587', flag: '\uD83C\uDDE8\uD83C\uDDF3' },
] as const;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
@ -19,37 +19,37 @@ export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
const supportedCodes: Set<string> = new Set(SUPPORTED_LANGUAGES.map((l) => l.code));
function detectLanguage(): string {
// 1. Explicit user choice (persisted from the language dropdown)
const stored = localStorage.getItem('language');
if (stored && supportedCodes.has(stored)) return stored;
// 1. Explicit user choice (persisted from the language dropdown)
const stored = localStorage.getItem('language');
if (stored && supportedCodes.has(stored)) return stored;
// 2. Browser preference (navigator.languages falls back to navigator.language)
for (const tag of navigator.languages ?? [navigator.language]) {
// Match full tag first (e.g. "zh-CN" → "zh"), then just the prefix
const lower = tag.toLowerCase();
if (supportedCodes.has(lower)) return lower;
const prefix = lower.split('-')[0];
if (supportedCodes.has(prefix)) return prefix;
}
// 2. Browser preference (navigator.languages falls back to navigator.language)
for (const tag of navigator.languages ?? [navigator.language]) {
// Match full tag first (e.g. "zh-CN" → "zh"), then just the prefix
const lower = tag.toLowerCase();
if (supportedCodes.has(lower)) return lower;
const prefix = lower.split('-')[0];
if (supportedCodes.has(prefix)) return prefix;
}
return 'en';
return 'en';
}
const initialLang = detectLanguage();
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
fr: { translation: fr },
de: { translation: de },
hu: { translation: hu },
zh: { translation: zh },
},
lng: initialLang,
fallbackLng: 'en',
interpolation: {
escapeValue: false, // React already escapes
},
resources: {
en: { translation: en },
fr: { translation: fr },
de: { translation: de },
hu: { translation: hu },
zh: { translation: zh },
},
lng: initialLang,
fallbackLng: 'en',
interpolation: {
escapeValue: false, // React already escapes
},
});
/**
@ -57,8 +57,8 @@ i18n.use(initReactI18next).init({
* Bypasses the strict type checking on t() for dynamic key construction.
*/
export function tDynamic(key: string): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (i18n.t as any)(key);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (i18n.t as any)(key);
}
export default i18n;

View file

@ -72,8 +72,7 @@ const de: Translations = {
logIn: 'Anmelden',
createAccount: 'Konto erstellen',
resetPassword: 'Passwort zurücksetzen',
valueProp:
'Speichere Suchen, merke dir Immobilien und mach dort weiter, wo du aufgehört hast.',
valueProp: 'Speichere Suchen, merke dir Immobilien und mach dort weiter, wo du aufgehört hast.',
continueWithGoogle: 'Weiter mit Google',
email: 'E-Mail',
emailPlaceholder: 'du@beispiel.de',
@ -120,8 +119,7 @@ const de: Translations = {
licenseSuccess: {
title: 'Du bist dabei.',
subtitle: 'Dein lebenslanger Zugang ist jetzt aktiv.',
description:
'Voller Zugang zu allen Funktionen, allen Postleitzahlen, in ganz England.',
description: 'Voller Zugang zu allen Funktionen, allen Postleitzahlen, in ganz England.',
startExploring: 'Jetzt entdecken',
},
@ -129,9 +127,6 @@ const de: Translations = {
filters: {
activeFilters: 'Aktive Filter',
addFilter: 'Filter hinzufügen',
historical: 'Historisch',
buy: 'Kaufen',
rent: 'Mieten',
findingPerfectPostcode: 'Die perfekte Postleitzahl finden',
addFiltersHint:
'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die deinen Kriterien entsprechen',
@ -139,8 +134,7 @@ const de: Translations = {
'Sieh Kriminalität, Schulen, Lärm, Breitband und 50+ weitere Filter für ganz England.',
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
upgradeToFullMap: 'Zur Vollversion upgraden',
chooseFilters:
'Wähle die Filter, die dir wichtig sind. Die Karte aktualisiert sich sofort.',
chooseFilters: 'Wähle die Filter, die dir wichtig sind. Die Karte aktualisiert sich sofort.',
searchFeatures: 'Filter durchsuchen...',
noMatchingFeatures: 'Keine passenden Filter',
tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
@ -204,14 +198,11 @@ const de: Translations = {
travelInfo: {
transitDesc:
' mit öffentlichen Verkehrsmitteln (Bus, Bahn, U-Bahn). Die Zeiten werden über ein typisches Werktags-Morgenfenster berechnet.',
carDesc:
' mit dem Auto, basierend auf typischen Straßengeschwindigkeiten und dem Straßennetz.',
carDesc: ' mit dem Auto, basierend auf typischen Straßengeschwindigkeiten und dem Straßennetz.',
bicycleDesc: ' mit dem Fahrrad, auf fahrradfreundlichen Strecken.',
walkingDesc: ' zu Fuß, über Fußwege und Bürgersteige.',
mainDesc:
'Zeigt, wie lange es dauert, das ausgewählte Ziel von jedem Gebiet aus zu erreichen',
sliderHint:
'Verwende den Schieberegler, um deine maximale Pendelzeit festzulegen.',
mainDesc: 'Zeigt, wie lange es dauert, das ausgewählte Ziel von jedem Gebiet aus zu erreichen',
sliderHint: 'Verwende den Schieberegler, um deine maximale Pendelzeit festzulegen.',
},
// ── AI Filter ──────────────────────────────────────
@ -220,9 +211,9 @@ const de: Translations = {
aiSearch: 'KI-Suche',
describeHint: 'beschreibe, wonach du suchst',
placeholder: 'z. B. ruhige Gegend, unter £400k, nahe guten Schulen...',
example1: 'Sichere Gegend nahe guten Schulen',
example2: '30 Min. Pendelweg zu Kings Cross, unter £500k',
example3: 'Ruhiges Dorf, 3 Schlafzimmer, schnelles Breitband',
example1: 'Haus 40 Min. von Bank in einer Gegend mit wenig Kriminalität',
example2: 'Wohnungen in der Nähe guter Grundschulen, nicht weit von Manchester',
example3: 'Beste Ex-Council-Häuser unter 200k',
analysing: 'Anfrage wird analysiert...',
searchingDestinations: 'Ziele werden gesucht...',
generatingFilters: 'Filter werden generiert...',
@ -235,8 +226,6 @@ const de: Translations = {
mapLegend: {
clearColourView: 'Farbansicht zurücksetzen',
historicalMatches: 'Historische Immobilientreffer',
propertiesForSale: 'Immobilien zum Verkauf',
propertiesForRent: 'Immobilien zur Miete',
numberOfProperties: 'Anzahl der Immobilien',
previewing: 'Vorschau von \u201c{{name}}\u201d',
},
@ -246,23 +235,18 @@ const de: Translations = {
unknownAddress: 'Unbekannte Adresse',
unsaveProperty: 'Immobilie nicht mehr merken',
saveProperty: 'Immobilie merken',
lastSold: 'Letzter Verkauf: £{{price}}',
estValue: 'Gesch. Wert:',
type: 'Typ:',
builtForm: 'Bauweise:',
tenure: 'Besitzart:',
floorArea: 'Wohnfläche:',
bedrooms: 'Schlafzimmer:',
bathrooms: 'Badezimmer:',
rooms: 'Zimmer:',
built: 'Baujahr:',
formerCouncil: 'Ehem. Sozialbau:',
exCouncilBadge: 'Ehem. Sozialbau',
epcRating: 'EPC-Bewertung:',
epcPotential: 'EPC-Potenzial:',
listed: 'Inseriert:',
keyFeatures: 'Hauptmerkmale',
renovations: 'Renovierungen',
viewExternalListing: 'Externes Inserat ansehen',
perMonth: '/Monat',
perSqm: '/m²',
searchPlaceholder: 'Nach Adresse oder Postleitzahl suchen...',
propertyData: 'Immobiliendaten',
@ -283,6 +267,7 @@ const de: Translations = {
viewOnGoogleMaps: 'Auf Google Maps ansehen',
walk: 'Zu Fuß',
cycle: 'Fahrrad',
nationalAvg: 'Landesdurchschnitt',
},
// ── Histogram Legend ───────────────────────────────
@ -313,6 +298,7 @@ const de: Translations = {
// ── External Search Links ──────────────────────────
externalSearch: {
searchOn: '{{radius}} suchen auf',
exact: 'genau',
outcodeNotRecognised: 'Postleitzahlenbereich nicht erkannt',
},
@ -358,13 +344,11 @@ const de: Translations = {
howStep2Title: 'Entdecke Gebiete und versteckte Perlen',
howStep2Desc: 'Zoom rein, schau dir Details und Kann-Kriterien an.',
howStep3Title: 'Einzelne Postleitzahlen erkunden',
howStep3Desc:
'Sieh einzelne Immobilien, Verkaufspreise, Wohnflächen und vergleiche.',
howStep3Desc: 'Sieh einzelne Immobilien, Verkaufspreise, Wohnflächen und vergleiche.',
howStep4Title: 'Engere Auswahl mit Zuversicht',
howStep4Desc:
'Jedes Gebiet auf deiner Liste erfüllt deine tatsächlichen Kriterien — nicht nur, was diese Woche inseriert war.',
othersVs: 'Andere vs',
listingPortals: 'Immobilienportale',
checkMyPostcode: '„Meine Postleitzahl prüfen“',
areaGuides: 'Gebietsratgeber',
compSearchWithout: 'Suchen, ohne zuerst ein Gebiet auszuwählen',
@ -375,17 +359,14 @@ const de: Translations = {
compPropertyDataSub: '(Preis, EPC, Wohnfläche)',
compFilters: '56 kombinierbare Filter an einem Ort',
compFiltersSub: '(alle Einblicke, eine interaktive Karte)',
ctaTitle:
'Mach aus deiner größten Investition deine klügste Entscheidung.',
ctaDescription:
'Das verdient die richtigen Werkzeuge — überlass es nicht dem Zufall.',
ctaTitle: 'Mach aus deiner größten Investition deine klügste Entscheidung.',
ctaDescription: 'Das verdient die richtigen Werkzeuge — überlass es nicht dem Zufall.',
},
// ── Pricing Page ───────────────────────────────────
pricingPage: {
title: 'Frühzugangspreis',
subtitle:
'Einmal zahlen, für immer nutzen. Je früher du dabei bist, desto weniger zahlst du.',
subtitle: 'Einmal zahlen, für immer nutzen. Je früher du dabei bist, desto weniger zahlst du.',
costContext:
'Ein Hauskauf kostet £10.000+ an Grunderwerbsteuer, £1.500 an Anwaltsgebühren, £500 für ein Gutachten. Wählst du das falsche Gebiet, steckst du mit einem langen Pendelweg, schlechten Schulen oder einer Straße fest, von der du nichts wusstest.',
lessThanSurvey: 'Weniger als ein Hausgutachten. Deutlich nützlicher.',
@ -404,8 +385,7 @@ const de: Translations = {
moneyBackGuarantee: '30 Tage Geld-zurück-Garantie',
soldOut: 'Ausverkauft',
upcoming: 'Demnächst',
failedToLoad:
'Preise konnten nicht geladen werden. Bitte später erneut versuchen.',
failedToLoad: 'Preise konnten nicht geladen werden. Bitte später erneut versuchen.',
feat1: '56 Datenebenen für ganz England',
feat2: 'Jede Postleitzahl bewertet und filterbar',
feat3: 'Unbegrenztes Erkunden der Karte und Exporte',
@ -419,13 +399,17 @@ const de: Translations = {
faq: 'Häufige Fragen',
dataSources: 'Datenquellen',
support: 'Support',
dataSourcesIntro: 'Diese Anwendung kombiniert {{count}} offene Datensätze zu Immobilienpreisen, Energieeffizienz, Verkehr, Demografie, Kriminalität, Umwelt und mehr.',
faqIntro: 'Ob Sie kaufen, mieten oder einfach nur stöbern so hilft Ihnen Perfect Postcode, das richtige Gebiet zu finden.',
supportIntro: 'Haben Sie eine Frage? Schauen Sie in unsere FAQ oder kontaktieren Sie uns direkt.',
dataSourcesIntro:
'Diese Anwendung kombiniert {{count}} offene Datensätze zu Immobilienpreisen, Energieeffizienz, Verkehr, Demografie, Kriminalität, Umwelt und mehr.',
faqIntro:
'Ob Sie kaufen, mieten oder einfach nur stöbern so hilft Ihnen Perfect Postcode, das richtige Gebiet zu finden.',
supportIntro:
'Haben Sie eine Frage? Schauen Sie in unsere FAQ oder kontaktieren Sie uns direkt.',
source: 'Quelle:',
optOut: 'Widerspruch gegen öffentliche Offenlegung',
attribution: 'Quellenangaben',
attrLandRegistry: 'Enthält Daten des HM Land Registry © Crown copyright and database right 2025.',
attrLandRegistry:
'Enthält Daten des HM Land Registry © Crown copyright and database right 2025.',
attrOgl: 'Enthält öffentliche Informationen lizenziert unter der',
attrOglLink: 'Open Government Licence v3.0',
attrOs: 'Enthält OS-Daten © Crown copyright and database rights 2025.',
@ -440,43 +424,60 @@ const de: Translations = {
dsPricePaidUse: 'Vollständige historische Immobilien-Verkaufspreise für England.',
dsEpcName: 'Energy Performance Certificates (EPC)',
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
dsEpcUse: 'Energieausweise für Wohngebäude mit Angaben zu Wohnfläche, Zimmeranzahl, Baujahr, Energiebewertungen, Immobilientyp und Bauform. Über Adresse innerhalb jeder Postleitzahl mit Price-Paid-Daten verknüpft. Eigentümer können der öffentlichen Offenlegung widersprechen.',
dsEpcUse:
'Energieausweise für Wohngebäude mit Angaben zu Wohnfläche, Zimmeranzahl, Baujahr, Energiebewertungen, Immobilientyp und Bauform. Über Adresse innerhalb jeder Postleitzahl mit Price-Paid-Daten verknüpft. Eigentümer können der öffentlichen Offenlegung widersprechen.',
dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
dsNsplOrigin: 'ONS / ArcGIS',
dsNsplUse: 'Ordnet Postleitzahlen Koordinaten und statistischen Gebietscodes zu, um alle gebietsbezogenen Datensätze mit einzelnen Immobilien zu verknüpfen.',
dsNsplUse:
'Ordnet Postleitzahlen Koordinaten und statistischen Gebietscodes zu, um alle gebietsbezogenen Datensätze mit einzelnen Immobilien zu verknüpfen.',
dsIodName: 'English Indices of Deprivation 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse: 'Relative Benachteiligungswerte für Einkommen, Beschäftigung, Bildung, Gesundheit, Kriminalität und Wohnumfeld für jedes Viertel in England.',
dsIodUse:
'Relative Benachteiligungswerte für Einkommen, Beschäftigung, Bildung, Gesundheit, Kriminalität und Wohnumfeld für jedes Viertel in England.',
dsEthnicityName: 'Bevölkerung nach Ethnie (Zensus 2021)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse: 'Bevölkerungsanteile nach ethnischer Gruppe (südasiatisch, ostasiatisch, schwarz, gemischt, weiß, andere) pro Bezirk.',
dsEthnicityUse:
'Bevölkerungsanteile nach ethnischer Gruppe (südasiatisch, ostasiatisch, schwarz, gemischt, weiß, andere) pro Bezirk.',
dsCrimeName: 'Street-level Crime Data',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse: 'Kriminalitätsdaten auf Straßenebene von 2023 bis 2025, aggregiert als Jahresdurchschnitte nach LSOA und Deliktsart (Gewalt, Einbruch, antisoziales Verhalten, Drogen, Fahrzeugkriminalität usw.).',
dsCrimeUse:
'Kriminalitätsdaten auf Straßenebene von 2023 bis 2025, aggregiert als Jahresdurchschnitte nach LSOA und Deliktsart (Gewalt, Einbruch, antisoziales Verhalten, Drogen, Fahrzeugkriminalität usw.).',
dsOsmName: 'OpenStreetMap POIs',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse: 'Sehenswürdigkeiten und Einrichtungen wie Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit, Tourismus und mehr in ganz Großbritannien.',
dsOsmUse:
'Sehenswürdigkeiten und Einrichtungen wie Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit, Tourismus und mehr in ganz Großbritannien.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse: 'Offizielle Grünflächengrenzen für Großbritannien, einschließlich öffentlicher Parks, Gärten, Sportplätze und Spielplätze. Polygon-Schwerpunkte werden für die Parknähezählung und Entfernungsberechnung zum nächsten Park verwendet.',
dsGreenspaceUse:
'Offizielle Grünflächengrenzen für Großbritannien, einschließlich öffentlicher Parks, Gärten, Sportplätze und Spielplätze. Polygon-Schwerpunkte werden für die Parknähezählung und Entfernungsberechnung zum nächsten Park verwendet.',
dsNaptanName: 'NaPTAN (Public Transport Stops)',
dsNaptanOrigin: 'Department for Transport',
dsNaptanUse: 'Standorte von Bahnhöfen und Haltestellen für Bahn, Bus, U-Bahn/Straßenbahn, Fähre und Flughäfen in ganz England.',
dsNaptanUse:
'Standorte von Bahnhöfen und Haltestellen für Bahn, Bus, U-Bahn/Straßenbahn, Fähre und Flughäfen in ganz England.',
dsNoiseName: 'Defra Noise Mapping',
dsNoiseOrigin: 'Defra / Environment Agency',
dsNoiseUse: 'Straßenlärmpegel (24-Stunden-gewichteter Durchschnitt) aus der strategischen Lärmkartierung 2022, hochauflösend modelliert und an jeder Postleitzahl abgetastet.',
dsNoiseUse:
'Straßenlärmpegel (24-Stunden-gewichteter Durchschnitt) aus der strategischen Lärmkartierung 2022, hochauflösend modelliert und an jeder Postleitzahl abgetastet.',
dsOfstedName: 'Ofsted School Inspections',
dsOfstedOrigin: 'Ofsted',
dsOfstedUse: 'Neueste Inspektionsergebnisse für staatlich finanzierte Schulen (Stand April 2025). Pro Postleitzahl gemittelt für einen lokalen Schulqualitätswert (1=Hervorragend bis 4=Unzureichend).',
dsOfstedUse:
'Neueste Inspektionsergebnisse für staatlich finanzierte Schulen (Stand April 2025). Pro Postleitzahl gemittelt für einen lokalen Schulqualitätswert (1=Hervorragend bis 4=Unzureichend).',
dsBroadbandName: 'Ofcom Broadband Performance',
dsBroadbandOrigin: 'Ofcom',
dsBroadbandUse: 'Festnetz-Breitbandabdeckung und maximale Download-Geschwindigkeiten nach Gebiet aus Ofcom Connected Nations 2025.',
dsBroadbandUse:
'Festnetz-Breitbandabdeckung und maximale Download-Geschwindigkeiten nach Gebiet aus Ofcom Connected Nations 2025.',
dsCouncilTaxName: 'Council Tax Levels 2025-26',
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
dsCouncilTaxUse: 'Jährliche Council-Tax-Sätze für die Stufen A bis H für alle 296 Abrechnungsbehörden in England, für eine von zwei Erwachsenen bewohnte Immobilie. Über den Bezirkscode aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verknüpft.',
dsCouncilTaxUse:
'Jährliche Council-Tax-Sätze für die Stufen A bis H für alle 296 Abrechnungsbehörden in England, für eine von zwei Erwachsenen bewohnte Immobilie. Über den Bezirkscode aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verknüpft.',
dsRentalName: 'Private Rental Market Statistics',
dsRentalOrigin: 'ONS / Valuation Office Agency',
dsRentalUse: 'Monatliche Medianmieten des privaten Mietmarkts nach Bezirk und Schlafzimmerkategorie (Okt. 2022 - Sept. 2023). Über Bezirkscode und geschätzte Schlafzimmeranzahl mit Immobilien verknüpft.',
dsRentalUse:
'Monatliche Medianmieten des privaten Mietmarkts nach Bezirk und Schlafzimmerkategorie (Okt. 2022 - Sept. 2023). Über Bezirkscode und geschätzte Schlafzimmeranzahl mit Immobilien verknüpft.',
dsElectionName: 'Ergebnisse der Parlamentswahl 2024',
dsElectionOrigin: 'Britisches Parlament',
dsElectionUse:
'Ergebnisse auf Kandidatenebene der britischen Parlamentswahl vom Juli 2024. Aggregiert auf Wahlkreisebene: siegreiche Partei, Wahlbeteiligung (%) und Mehrheit (%). Über den Wahlkreiscode (pcon) aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verknüpft.',
// FAQ section titles
faqFindingTitle: 'Ihr Gebiet finden',
faqCommuteTitle: 'Pendelweg und Reisezeit',
@ -488,62 +489,92 @@ const de: Translations = {
faqPricingTitle: 'Preise und Zugang',
faqTipsTitle: 'Tipps und Tricks',
// FAQ items — Finding Your Area
faqFinding1Q: 'Ich weiß nicht einmal, welche Gebiete ich mir ansehen soll. Kann mir das helfen?',
faqFinding1A: 'Genau dafür ist es da. Legen Sie Ihre Filter fest (Budget, Pendelzeit, geringe Kriminalität, gute Schulen) und die Karte leuchtet auf, um Ihnen jedes Gebiet zu zeigen, das alle Kriterien erfüllt. Kein nächtliches Googeln nach „beste Wohngegenden bei Manchester“ mehr.',
faqFinding1Q:
'Ich weiß nicht einmal, welche Gebiete ich mir ansehen soll. Kann mir das helfen?',
faqFinding1A:
'Genau dafür ist es da. Legen Sie Ihre Filter fest (Budget, Pendelzeit, geringe Kriminalität, gute Schulen) und die Karte leuchtet auf, um Ihnen jedes Gebiet zu zeigen, das alle Kriterien erfüllt. Kein nächtliches Googeln nach „beste Wohngegenden bei Manchester“ mehr.',
faqFinding2Q: 'Ich ziehe irgendwohin, wo ich noch nie war. Wie fange ich überhaupt an?',
faqFinding2A: 'Stellen Sie Ihre Filter für das ein, was Ihnen wichtig ist, und die Karte hebt sofort die passenden Gebiete hervor. Sie gehen von „Ich kenne keine einzige Straße“ zu einer Auswahlliste in wenigen Minuten.',
faqFinding2A:
'Stellen Sie Ihre Filter für das ein, was Ihnen wichtig ist, und die Karte hebt sofort die passenden Gebiete hervor. Sie gehen von „Ich kenne keine einzige Straße“ zu einer Auswahlliste in wenigen Minuten.',
faqFinding3Q: 'Wie finde ich Gebiete, die alle meine Kriterien gleichzeitig erfüllen?',
faqFinding3A: 'Kombinieren Sie mehrere Filter (Kriminalität unter dem Durchschnitt, gute Schulen, Pendelweg unter 40 Minuten) und färben Sie die Karte nach Preis, um die Gebiete mit dem besten Preis-Leistungs-Verhältnis zu finden. Die Karte aktualisiert sich in Echtzeit, wenn Sie die Regler bewegen.',
faqFinding3A:
'Kombinieren Sie mehrere Filter (Kriminalität unter dem Durchschnitt, gute Schulen, Pendelweg unter 40 Minuten) und färben Sie die Karte nach Preis, um die Gebiete mit dem besten Preis-Leistungs-Verhältnis zu finden. Die Karte aktualisiert sich in Echtzeit, wenn Sie die Regler bewegen.',
// FAQ items — Commute and Travel
faqCommute1Q: 'Kann ich sehen, wie lange mein Pendelweg aus verschiedenen Gebieten tatsächlich dauern würde?',
faqCommute1A: 'Legen Sie Ihren Arbeitsplatz als Ziel fest und wir färben jede Postleitzahl nach Fahrzeit ob mit Auto, Fahrrad oder öffentlichen Verkehrsmitteln. Filtern Sie nach Ihrer maximalen Pendelzeit und der Rest verschwindet.',
faqCommute1Q:
'Kann ich sehen, wie lange mein Pendelweg aus verschiedenen Gebieten tatsächlich dauern würde?',
faqCommute1A:
'Legen Sie Ihren Arbeitsplatz als Ziel fest und wir färben jede Postleitzahl nach Fahrzeit ob mit Auto, Fahrrad oder öffentlichen Verkehrsmitteln. Filtern Sie nach Ihrer maximalen Pendelzeit und der Rest verschwindet.',
faqCommute2Q: 'Wie ist das besser als Google Maps?',
faqCommute2A: 'Google Maps zeigt Ihnen eine Fahrt auf einmal. Wir färben jede Postleitzahl in England nach Pendelzeit in einem Blick, sodass Sie Hunderte von Gebieten nebeneinander vergleichen können, anstatt sie einzeln zu suchen.',
faqCommute2A:
'Google Maps zeigt Ihnen eine Fahrt auf einmal. Wir färben jede Postleitzahl in England nach Pendelzeit in einem Blick, sodass Sie Hunderte von Gebieten nebeneinander vergleichen können, anstatt sie einzeln zu suchen.',
// FAQ items — Budget and Value
faqBudget1Q: 'Wie finde ich Gebiete, in denen ich am meisten Wohnfläche für mein Geld bekomme?',
faqBudget1A: 'Filtern Sie nach Preis pro m² und Sie sehen sofort, welche Postleitzahlen am meisten Fläche pro Pfund bieten. Kombinieren Sie es mit dem Energiebewertungsfilter, um Immobilien mit hohen Heizkosten zu vermeiden.',
faqBudget2Q: 'Wie stelle ich sicher, dass ein günstiges Gebiet nicht aus gutem Grund günstig ist?',
faqBudget2A: 'Legen Sie Benachteiligungswerte, Kriminalitätsstatistiken, Schulbewertungen und Breitbandgeschwindigkeiten neben den Preis. Wenn eine Postleitzahl erschwinglich ist und bei allem, was zählt, gut abschneidet, haben Sie echten Wert gefunden nicht nur einen niedrigen Preis mit Kompromissen, die Sie noch nicht bemerkt haben.',
faqBudget1A:
'Filtern Sie nach Preis pro m² und Sie sehen sofort, welche Postleitzahlen am meisten Fläche pro Pfund bieten. Kombinieren Sie es mit dem Energiebewertungsfilter, um Immobilien mit hohen Heizkosten zu vermeiden.',
faqBudget2Q:
'Wie stelle ich sicher, dass ein günstiges Gebiet nicht aus gutem Grund günstig ist?',
faqBudget2A:
'Legen Sie Benachteiligungswerte, Kriminalitätsstatistiken, Schulbewertungen und Breitbandgeschwindigkeiten neben den Preis. Wenn eine Postleitzahl erschwinglich ist und bei allem, was zählt, gut abschneidet, haben Sie echten Wert gefunden nicht nur einen niedrigen Preis mit Kompromissen, die Sie noch nicht bemerkt haben.',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: 'Wie kann ich prüfen, ob ein Gebiet sicher ist, bevor ich dorthin ziehe?',
faqSafety1A: 'Wir überlagern echte polizeilich erfasste Kriminalitätsdaten, aufgeschlüsselt nach Art, über jedes Viertel in England. Filtern Sie nach Gewaltkriminalität, Einbruch oder antisozialem Verhalten und sehen Sie sofort, welche Postleitzahlen die niedrigsten Zahlen haben.',
faqSafety2Q: 'Ich finde ständig Wohnungen, die online toll aussehen, aber dann stellt sich die Gegend als schwierig heraus.',
faqSafety2A: 'Genau dafür gibt es dieses Tool. Kombinieren Sie Kriminalitätsraten, Lärmpegel, Benachteiligungswerte, Pubs und Parks in der Nähe sowie Breitbandgeschwindigkeiten auf einer Karte, damit Sie wissen, wie ein Viertel wirklich ist, bevor Sie eine Besichtigung buchen.',
faqSafety1A:
'Wir überlagern echte polizeilich erfasste Kriminalitätsdaten, aufgeschlüsselt nach Art, über jedes Viertel in England. Filtern Sie nach Gewaltkriminalität, Einbruch oder antisozialem Verhalten und sehen Sie sofort, welche Postleitzahlen die niedrigsten Zahlen haben.',
faqSafety2Q:
'Ich finde ständig Wohnungen, die online toll aussehen, aber dann stellt sich die Gegend als schwierig heraus.',
faqSafety2A:
'Genau dafür gibt es dieses Tool. Kombinieren Sie Kriminalitätsraten, Lärmpegel, Benachteiligungswerte, Pubs und Parks in der Nähe sowie Breitbandgeschwindigkeiten auf einer Karte, damit Sie wissen, wie ein Viertel wirklich ist, bevor Sie eine Besichtigung buchen.',
// FAQ items — Families and Schools
faqFamilies1Q: 'Kann ich Gebiete mit guten Schulen UND geringer Kriminalität in einer Suche finden?',
faqFamilies1A: 'Ja. Kombinieren Sie Filter für Ofsted-Bewertungen, Kriminalitätsraten, Parks und alles andere, was für Ihre Familie wichtig ist, und die Karte hebt nur die Gebiete hervor, die alles erfüllen. Kein Abgleich über fünf verschiedene Websites mehr.',
faqFamilies1Q:
'Kann ich Gebiete mit guten Schulen UND geringer Kriminalität in einer Suche finden?',
faqFamilies1A:
'Ja. Kombinieren Sie Filter für Ofsted-Bewertungen, Kriminalitätsraten, Parks und alles andere, was für Ihre Familie wichtig ist, und die Karte hebt nur die Gebiete hervor, die alles erfüllen. Kein Abgleich über fünf verschiedene Websites mehr.',
faqFamilies2Q: 'Woher weiß ich, ob ein Viertel Parks und Spielplätze in der Nähe hat?',
faqFamilies2A: 'Schalten Sie die POI-Ebene für Parks und Grünflächen ein, um sie direkt auf der Karte zu sehen. Sie können auch nach der Anzahl der fußläufig erreichbaren Parks pro Postleitzahl filtern.',
faqFamilies2A:
'Schalten Sie die POI-Ebene für Parks und Grünflächen ein, um sie direkt auf der Karte zu sehen. Sie können auch nach der Anzahl der fußläufig erreichbaren Parks pro Postleitzahl filtern.',
// FAQ items — Environment and Quality of Life
faqEnv1Q: 'Kann ich energieeffiziente Wohnungen finden, die nicht an einer lauten Straße liegen?',
faqEnv1A: 'Filtern Sie nach EPC-Bewertung (A bis C), dann überlagern Sie die Straßenlärmdaten, um alles über Ihrem Schwellenwert auszuschließen. Färben Sie nach einem der beiden Kriterien, um ruhige, effiziente Straßen auf einen Blick zu erkennen.',
faqEnv1Q:
'Kann ich energieeffiziente Wohnungen finden, die nicht an einer lauten Straße liegen?',
faqEnv1A:
'Filtern Sie nach EPC-Bewertung (A bis C), dann überlagern Sie die Straßenlärmdaten, um alles über Ihrem Schwellenwert auszuschließen. Färben Sie nach einem der beiden Kriterien, um ruhige, effiziente Straßen auf einen Blick zu erkennen.',
faqEnv2Q: 'Zeigt es Hochwasser- oder Senkungsrisiken?',
faqEnv2A: 'Wir integrieren Bodenstabilitätsdaten, damit Sie vor dem Kauf auf Senkungen, Schrumpf-Quell-Tone und andere geologische Risiken prüfen können. Schließen Sie Risikogebiete frühzeitig aus.',
faqEnv2A:
'Wir integrieren Bodenstabilitätsdaten, damit Sie vor dem Kauf auf Senkungen, Schrumpf-Quell-Tone und andere geologische Risiken prüfen können. Schließen Sie Risikogebiete frühzeitig aus.',
faqEnv3Q: 'Kann ich Gebiete mit schnellem Breitband finden, die wirklich ruhig sind?',
faqEnv3A: 'Überlagern Sie den Breitbandfilter mit den Straßenlärmdaten, um Straßen mit guter Anbindung und wenig Verkehrslärm zu finden. Färben Sie nach einem der beiden Kriterien, um Gebiete auf einen Blick zu vergleichen.',
faqEnv3A:
'Überlagern Sie den Breitbandfilter mit den Straßenlärmdaten, um Straßen mit guter Anbindung und wenig Verkehrslärm zu finden. Färben Sie nach einem der beiden Kriterien, um Gebiete auf einen Blick zu vergleichen.',
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'Ich benutze bereits Rightmove. Was bringt mir das zusätzlich?',
faqWhy1A: 'Rightmove zeigt Ihnen Häuser. Wir zeigen Ihnen Gebiete. Kriminalitätsraten, Schulbewertungen, Breitbandgeschwindigkeiten, Lärmpegel, Benachteiligungswerte und mehr alles filterbar auf einer Karte. Sie können ein Viertel beurteilen, bevor Sie sich die Angebote ansehen.',
faqWhy1A:
'Rightmove zeigt Ihnen Häuser. Wir zeigen Ihnen Gebiete. Kriminalitätsraten, Schulbewertungen, Breitbandgeschwindigkeiten, Lärmpegel, Benachteiligungswerte und mehr alles filterbar auf einer Karte. Sie können ein Viertel beurteilen, bevor Sie sich die Angebote ansehen.',
faqWhy2Q: 'Kann ich das nicht alles kostenlos selbst recherchieren?',
faqWhy2A: 'Sie könnten Polizeidaten, Ofsted-Berichte, EPC-Register, Land-Registry-Einträge und ONS-Statistiken eine Postleitzahl nach der anderen abgleichen. Oder Sie haben alles filterbar und farbkodiert auf einer Karte in Sekunden.',
faqWhy2A:
'Sie könnten Polizeidaten, Ofsted-Berichte, EPC-Register, Land-Registry-Einträge und ONS-Statistiken eine Postleitzahl nach der anderen abgleichen. Oder Sie haben alles filterbar und farbkodiert auf einer Karte in Sekunden.',
faqWhy3Q: 'Woher stammen die Daten tatsächlich?',
faqWhy3A: 'Jeder Datensatz stammt aus offiziellen britischen Regierungsquellen: Land Registry, EPC-Register, ONS, Ofsted, Ofcom, data.police.uk und Defra. Wir scrapen keine Makler und erfinden nichts. Sie können jeden Eintrag anhand der Originalquelle überprüfen.',
faqWhy3A:
'Jeder Datensatz stammt aus offiziellen britischen Regierungsquellen: Land Registry, EPC-Register, ONS, Ofsted, Ofcom, data.police.uk und Defra. Wir scrapen keine Makler und erfinden nichts. Sie können jeden Eintrag anhand der Originalquelle überprüfen.',
// FAQ items — Pricing and Access
faqPricing1Q: 'Lohnt es sich wirklich, für ein Immobilien-Suchtool zu bezahlen?',
faqPricing1A: 'Ein Hauskauf ist wahrscheinlich die größte Anschaffung Ihres Lebens. Ein einziges Warnsignal zu erkennen (eine laute Straße, schlechtes Breitband, steigende Kriminalität) bevor Sie sich festlegen, könnte Ihnen Jahre des Bedauerns ersparen. Das kostet weniger als eine Tankfüllung.',
faqPricing1A:
'Ein Hauskauf ist wahrscheinlich die größte Anschaffung Ihres Lebens. Ein einziges Warnsignal zu erkennen (eine laute Straße, schlechtes Breitband, steigende Kriminalität) bevor Sie sich festlegen, könnte Ihnen Jahre des Bedauerns ersparen. Das kostet weniger als eine Tankfüllung.',
faqPricing2Q: 'Ist das ein Abonnement?',
faqPricing2A: 'Nein. Einmalzahlung, Ihres für immer. Nutzen Sie es intensiv während Ihrer Suche, kommen Sie zurück, wenn Sie neugierig auf ein neues Gebiet sind, und es ist immer noch da, falls Sie erneut umziehen.',
faqPricing2A:
'Nein. Einmalzahlung, Ihres für immer. Nutzen Sie es intensiv während Ihrer Suche, kommen Sie zurück, wenn Sie neugierig auf ein neues Gebiet sind, und es ist immer noch da, falls Sie erneut umziehen.',
faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?',
faqPricing3A: 'Kostenlose Nutzer können alle Funktionen im Demogebiet erkunden (Innenstadt London, ungefähr Zonen 1 bis 2). Für den Zugang zu Daten für den Rest Englands benötigen Sie den lebenslangen Zugang.',
faqPricing3A:
'Kostenlose Nutzer können alle Funktionen im Demogebiet erkunden (Innenstadt London, ungefähr Zonen 1 bis 2). Für den Zugang zu Daten für den Rest Englands benötigen Sie den lebenslangen Zugang.',
faqPricing4Q: 'Kann ich eine Rückerstattung erhalten?',
faqPricing4A: 'Selbstverständlich. Wir bieten eine 30-Tage-Geld-zurück-Garantie. Wenn Sie nicht zufrieden sind, schreiben Sie innerhalb von 30 Tagen an support@perfect-postcode.co.uk für eine vollständige Rückerstattung.',
faqPricing4A:
'Selbstverständlich. Wir bieten eine 30-Tage-Geld-zurück-Garantie. Wenn Sie nicht zufrieden sind, schreiben Sie innerhalb von 30 Tagen an support@perfect-postcode.co.uk für eine vollständige Rückerstattung.',
// FAQ items — Tips and Tricks
faqTips1Q: 'Wie nutze ich den KI-Filter, anstatt Filter einzeln hinzuzufügen?',
faqTips1A: 'Beschreiben Sie, was Sie suchen, z. B. „ruhige Gegend nahe guten Schulen mit schnellem Breitband unter £400k“, und die KI richtet alle relevanten Filter auf einmal ein. Passen Sie danach manuell an.',
faqTips1A:
'Beschreiben Sie, was Sie suchen, z. B. „ruhige Gegend nahe guten Schulen mit schnellem Breitband unter £400k“, und die KI richtet alle relevanten Filter auf einmal ein. Passen Sie danach manuell an.',
faqTips2Q: 'Kann ich eine Suche speichern und später darauf zurückkommen?',
faqTips2A: 'Klicken Sie auf Speichern und alles wird erfasst: Ihre Filter, die Zoomstufe und die angezeigte Datenebene. Machen Sie genau dort weiter, wo Sie aufgehört haben, oder teilen Sie den Link mit Ihrem Partner.',
faqTips2A:
'Klicken Sie auf Speichern und alles wird erfasst: Ihre Filter, die Zoomstufe und die angezeigte Datenebene. Machen Sie genau dort weiter, wo Sie aufgehört haben, oder teilen Sie den Link mit Ihrem Partner.',
faqTips3Q: 'Kann ich die angezeigten Daten exportieren?',
faqTips3A: 'Nutzen Sie den Export-Button, um die aktuell gefilterten Immobilien als Tabelle herunterzuladen. Der Export berücksichtigt alle aktiven Filter, sodass Sie genau die gewünschten Daten erhalten.',
faqTips3A:
'Nutzen Sie den Export-Button, um die aktuell gefilterten Immobilien als Tabelle herunterzuladen. Der Export berücksichtigt alle aktiven Filter, sodass Sie genau die gewünschten Daten erhalten.',
},
// ── Account Page ───────────────────────────────────
@ -567,7 +598,6 @@ const de: Translations = {
noSavedPropertiesDesc:
'Merke dir Immobilien während du erkundest und erstelle deine Auswahlliste, ohne den Überblick zu verlieren.',
openPostcode: 'Postleitzahl öffnen',
viewListing: 'Inserat ansehen',
clickToRename: 'Klicken zum Umbenennen',
notesPlaceholder: 'Notiere deine Gedanken...',
deleteSearch: 'Suche löschen',
@ -582,8 +612,7 @@ const de: Translations = {
// ── Invites Page ───────────────────────────────────
invitesPage: {
inviteLinksLicensed:
'Einladungslinks sind für lizenzierte Nutzer verfügbar.',
inviteLinksLicensed: 'Einladungslinks sind für lizenzierte Nutzer verfügbar.',
inviteAdminLabel: 'Freunde einladen (100% Rabatt)',
inviteReferralLabel: 'Freunde einladen (30% Rabatt)',
generateFreeInvite: 'Kostenlosen Einladungslink erstellen',
@ -604,27 +633,20 @@ const de: Translations = {
invitePage: {
youreInvited: 'Du bist eingeladen!',
specialOffer: 'Sonderangebot!',
invitedByFree:
'{{name}} hat dich eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
invitedByDiscount:
'{{name}} hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
genericFreeInvite:
'Du wurdest eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
genericDiscount:
'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
invitedByFree: '{{name}} hat dich eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
invitedByDiscount: '{{name}} hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
genericFreeInvite: 'Du wurdest eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
exploreEvery: 'Entdecke jedes Viertel in England',
propertyInfo:
'Immobilienpreise, Energiebewertungen, Kriminalitätsstatistiken, Schulbewertungen und mehr',
invalidInvite: 'Ungültige Einladung',
inviteAlreadyUsed: 'Einladung bereits verwendet',
inviteAlreadyUsedDesc:
'Dieser Einladungslink wurde bereits eingelöst.',
inviteAlreadyUsedDesc: 'Dieser Einladungslink wurde bereits eingelöst.',
invalidInviteLink: 'Ungültiger Einladungslink',
invalidInviteLinkDesc:
'Dieser Einladungslink ist ungültig oder abgelaufen.',
invalidInviteLinkDesc: 'Dieser Einladungslink ist ungültig oder abgelaufen.',
licenseActivated: 'Lizenz aktiviert!',
fullAccessGranted:
'Du hast jetzt vollen Zugang zu Perfect Postcode.',
fullAccessGranted: 'Du hast jetzt vollen Zugang zu Perfect Postcode.',
activating: 'Wird aktiviert...',
activateLicense: 'Lizenz aktivieren',
claimDiscount: 'Rabatt einlösen',
@ -663,17 +685,23 @@ const de: Translations = {
// ── Tutorial ──────────────────────────────────────
tutorial: {
step1Title: 'Sagen Sie der Karte, was zählt',
step1Content: 'Legen Sie Ihr Budget, maximale Pendelzeit, Schulqualität und Kriminalitätsschwelle fest. Was Ihnen wichtig ist. Nur qualifizierende Gebiete bleiben hervorgehoben. Nutzen Sie das Augensymbol, um nach beliebigem Merkmal einzufärben.',
step1Content:
'Legen Sie Ihr Budget, maximale Pendelzeit, Schulqualität und Kriminalitätsschwelle fest. Was Ihnen wichtig ist. Nur qualifizierende Gebiete bleiben hervorgehoben. Nutzen Sie das Augensymbol, um nach beliebigem Merkmal einzufärben.',
step2Title: 'Oder einfach beschreiben',
step2Content: 'Tippen Sie auf Deutsch ein, was Sie suchen, z. B. „ruhige Gegend nahe guter Schulen unter £400k“, und wir richten die Filter für Sie ein.',
step2Content:
'Tippen Sie auf Deutsch ein, was Sie suchen, z. B. „ruhige Gegend nahe guter Schulen unter £400k“, und wir richten die Filter für Sie ein.',
step3Title: 'Erkunden Sie, was es gibt',
step3Content: 'Schwenken und zoomen Sie durch England. Klicken Sie auf ein beliebiges farbiges Gebiet, um Kriminalität, Schulen, Preise, Breitband, Lärm und mehr zu sehen.',
step3Content:
'Schwenken und zoomen Sie durch England. Klicken Sie auf ein beliebiges farbiges Gebiet, um Kriminalität, Schulen, Preise, Breitband, Lärm und mehr zu sehen.',
step4Title: 'Direkt zu einem Ort springen',
step4Content: 'Suchen Sie nach einem Ort oder einer Postleitzahl, um sofort dorthin zu gelangen.',
step4Content:
'Suchen Sie nach einem Ort oder einer Postleitzahl, um sofort dorthin zu gelangen.',
step5Title: 'Ins Detail gehen',
step5Content: 'Sehen Sie Gebietsstatistiken, Histogramme und einzelne Immobiliendaten: Preise, Wohnfläche, Energiebewertungen und mehr.',
step5Content:
'Sehen Sie Gebietsstatistiken, Histogramme und einzelne Immobiliendaten: Preise, Wohnfläche, Energiebewertungen und mehr.',
step6Title: 'Was ist in der Nähe?',
step6Content: 'Blenden Sie Schulen, Geschäfte, Bahnhöfe, Parks und Restaurants auf der Karte ein, um zu sehen, was erreichbar ist.',
step6Content:
'Blenden Sie Schulen, Geschäfte, Bahnhöfe, Parks und Restaurants auf der Karte ein, um zu sehen, was erreichbar ist.',
},
// ── Server-derived values ──────────────────────────
@ -681,40 +709,35 @@ const de: Translations = {
// The English keys MUST match exactly what the API returns.
server: {
// ─ Feature group names ─
'Properties': 'Immobilien',
'Transport': 'Verkehr',
'Education': 'Bildung',
'Deprivation': 'Benachteiligung',
'Crime': 'Kriminalität',
'Demographics': 'Demografie',
'Amenities': 'Infrastruktur',
Properties: 'Immobilien',
Transport: 'Verkehr',
Education: 'Bildung',
Deprivation: 'Benachteiligung',
Crime: 'Kriminalität',
Demographics: 'Demografie',
Politics: 'Politik',
Amenities: 'Infrastruktur',
// ─ Feature names (Properties) ─
'Listing status': 'Inseratsstatus',
'Property type': 'Immobilientyp',
'Leasehold/Freehold': 'Erbbaurecht/Volleigentum',
'Last known price': 'Letzter bekannter Preis',
'Estimated current price': 'Geschätzter aktueller Preis',
'Asking price': 'Angebotspreis',
'Price per sqm': 'Preis pro m²',
'Est. price per sqm': 'Gesch. Preis pro m²',
'Asking price per sqm': 'Angebotspreis pro m²',
'Estimated monthly rent': 'Geschätzte Monatsmiete',
'Asking rent (monthly)': 'Angebotsmiete (monatlich)',
'Total floor area (sqm)': 'Gesamtwohnfläche (m²)',
'Number of bedrooms & living rooms': 'Anzahl Schlaf- & Wohnzimmer',
'Bedrooms': 'Schlafzimmer',
'Bathrooms': 'Badezimmer',
'Construction year': 'Baujahr',
'Date of last transaction': 'Datum der letzten Transaktion',
'Listing date': 'Inseratsdatum',
'Former council house': 'Ehemaliger Sozialbau',
'Current energy rating': 'Aktuelle Energiebewertung',
'Potential energy rating': 'Potenzielle Energiebewertung',
'Interior height (m)': 'Raumhöhe (m)',
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)': 'Entfernung zum nächsten Bahn- oder U-Bahnhof (km)',
'Distance to nearest train or tube station (km)':
'Entfernung zum nächsten Bahn- oder U-Bahnhof (km)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Gute+ Grundschulen im Umkreis von 2 km',
@ -732,8 +755,10 @@ const de: Translations = {
'Outdoors Sub-domain Score': 'Score der Umgebungsqualität (außen)',
// ─ Feature names (Crime) ─
'Serious crime per 1k residents (avg/yr)': 'Schwere Straftaten pro 1k Einwohner (Durchschn./Jahr)',
'Minor crime per 1k residents (avg/yr)': 'Leichte Straftaten pro 1k Einwohner (Durchschn./Jahr)',
'Serious crime per 1k residents (avg/yr)':
'Schwere Straftaten pro 1k Einwohner (Durchschn./Jahr)',
'Minor crime per 1k residents (avg/yr)':
'Leichte Straftaten pro 1k Einwohner (Durchschn./Jahr)',
'Serious crime (avg/yr)': 'Schwere Straftaten (Durchschn./Jahr)',
'Minor crime (avg/yr)': 'Leichte Straftaten (Durchschn./Jahr)',
'Violence and sexual offences (avg/yr)': 'Gewalt- und Sexualdelikte (Durchschn./Jahr)',
@ -760,28 +785,42 @@ const de: Translations = {
'% Mixed': '% Gemischt',
'% Other': '% Sonstige',
// ─ Feature names (Politics) ─
'Winning party': 'Siegreiche Partei',
'Voter turnout (%)': 'Wahlbeteiligung (%)',
'Majority (%)': 'Mehrheit (%)',
'% Labour': '% Labour',
'% Conservative': '% Conservative',
'% Liberal Democrat': '% Liberal Democrat',
'% Reform UK': '% Reform UK',
'% Green': '% Grüne',
'% Other parties': '% Sonstige Parteien',
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Entfernung zum nächsten Park (km)',
'Number of parks within 2km': 'Anzahl Parks im Umkreis von 2 km',
'Number of parks within 1km': 'Anzahl Parks im Umkreis von 1 km',
'Number of restaurants within 2km': 'Anzahl Restaurants im Umkreis von 2 km',
'Number of grocery shops and supermarkets within 2km': 'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
'Number of grocery shops and supermarkets within 2km':
'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
'Noise (dB)': 'Lärm (dB)',
'Max available download speed (Mbps)': 'Max. verfügbare Downloadgeschwindigkeit (Mbps)',
// ─ Enum values ─
'Historical sale': 'Historischer Verkauf',
'For sale': 'Zum Verkauf',
'For rent': 'Zur Miete',
'Detached': 'Freistehend',
Labour: 'Labour',
Conservative: 'Conservative',
'Liberal Democrat': 'Liberal Democrat',
'Reform UK': 'Reform UK',
Green: 'Grüne',
'Other parties': 'Sonstige Parteien',
Detached: 'Freistehend',
'Semi-Detached': 'Doppelhaushälfte',
'Terraced': 'Reihenhaus',
Terraced: 'Reihenhaus',
'Flats/Maisonettes': 'Wohnungen/Maisonetten',
'Other': 'Sonstige',
'Freehold': 'Volleigentum',
'Leasehold': 'Erbbaurecht',
'Yes': 'Ja',
'No': 'Nein',
Other: 'Sonstige',
Freehold: 'Volleigentum',
Leasehold: 'Erbbaurecht',
Yes: 'Ja',
No: 'Nein',
// ─ Stacked chart labels ─
'Serious crime': 'Schwere Straftaten',
@ -790,52 +829,52 @@ const de: Translations = {
// ─ POI group names ─
'Public Transport': 'Öffentlicher Nahverkehr',
'Leisure': 'Freizeit',
'Health': 'Gesundheit',
Leisure: 'Freizeit',
Health: 'Gesundheit',
'Emergency Services': 'Rettungsdienste',
'Groceries': 'Lebensmittel',
Groceries: 'Lebensmittel',
'Local Businesses': 'Lokale Geschäfte',
'Culture': 'Kultur',
'Services': 'Dienstleistungen',
'Shops': 'Geschäfte',
Culture: 'Kultur',
Services: 'Dienstleistungen',
Shops: 'Geschäfte',
// ─ POI categories ─
'Airport': 'Flughafen',
'Ferry': 'Fähre',
Airport: 'Flughafen',
Ferry: 'Fähre',
'Rail station': 'Bahnhof',
'Bus stop': 'Bushaltestelle',
'Bus station': 'Busbahnhof',
'Taxi rank': 'Taxistand',
'Metro or Tram stop': 'U-Bahn- oder Straßenbahnhaltestelle',
'Café': 'Café',
'Restaurant': 'Restaurant',
'Pub': 'Pub',
'Bar': 'Bar',
'Tube station': 'U-Bahn-Station',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',
Bar: 'Bar',
'Fast Food': 'Fast Food',
'Nightclub': 'Nachtclub',
'Cinema': 'Kino',
'Theatre': 'Theater',
Nightclub: 'Nachtclub',
Cinema: 'Kino',
Theatre: 'Theater',
'Live Music & Events': 'Live-Musik & Veranstaltungen',
'Park': 'Park',
'Playground': 'Spielplatz',
Park: 'Park',
Playground: 'Spielplatz',
'Sports Centre': 'Sportzentrum',
'Entertainment': 'Unterhaltung',
'Supermarket': 'Supermarkt',
Entertainment: 'Unterhaltung',
Supermarket: 'Supermarkt',
'Convenience Store': 'Spätkauf',
'Bakery': 'Bäckerei',
Bakery: 'Bäckerei',
'Butcher & Fishmonger': 'Metzgerei & Fischhändler',
'Greengrocer': 'Gemüsehändler',
Greengrocer: 'Gemüsehändler',
'Off-Licence': 'Getränkeladen',
'Deli & Specialty': 'Feinkost & Spezialitäten',
'Fashion & Clothing': 'Mode & Bekleidung',
'Electronics': 'Elektronik',
Electronics: 'Elektronik',
'Charity Shop': 'Secondhand-Laden',
'DIY & Hardware': 'Baumarkt & Eisenwaren',
'Home & Garden': 'Haus & Garten',
'Bookshop': 'Buchhandlung',
Bookshop: 'Buchhandlung',
'Pet Shop': 'Tierhandlung',
'Sports & Outdoor': 'Sport & Outdoor',
'Newsagent': 'Zeitungshändler',
Newsagent: 'Zeitungshändler',
'Department Store': 'Kaufhaus',
'Gift & Hobby': 'Geschenke & Hobby',
'Specialist Shop': 'Fachgeschäft',
@ -845,31 +884,31 @@ const de: Translations = {
'Car Services': 'Autoservice',
'Post Office': 'Postamt',
'Vet & Pet Care': 'Tierarzt & Tierpflege',
'Bank': 'Bank',
Bank: 'Bank',
'Travel Agent': 'Reisebüro',
'Police': 'Polizei',
Police: 'Polizei',
'Fire Station': 'Feuerwache',
'Ambulance Station': 'Rettungswache',
'GP Surgery': 'Hausarztpraxis',
'Dentist': 'Zahnarzt',
'Pharmacy': 'Apotheke',
Dentist: 'Zahnarzt',
Pharmacy: 'Apotheke',
'Hospital & Clinic': 'Krankenhaus & Klinik',
'Optician': 'Optiker',
'Physiotherapy': 'Physiotherapie',
Optician: 'Optiker',
Physiotherapy: 'Physiotherapie',
'Counselling & Therapy': 'Beratung & Therapie',
'Care Home': 'Pflegeheim',
'Medical & Mobility': 'Medizintechnik & Mobilität',
'Museum': 'Museum',
'Gallery': 'Galerie',
'Library': 'Bibliothek',
Museum: 'Museum',
Gallery: 'Galerie',
Library: 'Bibliothek',
'Place of Worship': 'Gebetsstätte',
'Arts Centre': 'Kunstzentrum',
'Zoo': 'Zoo',
Zoo: 'Zoo',
'Tourist Attraction': 'Touristenattraktion',
'School': 'Schule',
'Hotel': 'Hotel',
School: 'Schule',
Hotel: 'Hotel',
'Local Business': 'Lokales Geschäft',
'Offices': 'Büros',
Offices: 'Büros',
'EV Charging': 'E-Ladestation',
'Fuel Station': 'Tankstelle',
'Community Centre': 'Gemeindezentrum',

View file

@ -24,7 +24,8 @@ const en = {
properties: 'Properties',
postcode: 'Postcode',
noAreaSelected: 'No area selected',
noAreaSelectedDesc: 'Click any coloured area on the map to see crime, schools, prices, and more',
noAreaSelectedDesc:
'Click any coloured area on the map to see crime, schools, prices, and more',
clickForDetails: 'Click for details',
property: 'property',
propertiesPlural: 'properties',
@ -86,7 +87,8 @@ const en = {
// ── Upgrade Modal ──────────────────────────────────
upgrade: {
title: 'See all of England',
description: "You're currently exploring the demo area. Get lifetime access to every postcode, every filter, every neighbourhood. One payment, forever.",
description:
"Youre currently exploring the demo area. Get lifetime access to every postcode, every filter, every neighbourhood. One payment, forever.",
free: 'Free',
once: '/once',
freeForEarly: 'Free for early adopters. No credit card required.',
@ -113,7 +115,7 @@ const en = {
// ── License Success ────────────────────────────────
licenseSuccess: {
title: "You're in.",
title: "Youre in.",
subtitle: 'Your lifetime access is now active.',
description: 'Full access to every feature, every postcode, across all of England.',
startExploring: 'Start exploring',
@ -123,12 +125,10 @@ const en = {
filters: {
activeFilters: 'Active Filters',
addFilter: 'Add Filter',
historical: 'Historical',
buy: 'Buy',
rent: 'Rent',
findingPerfectPostcode: 'Finding the Perfect Postcode',
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
upgradePrompt: 'See crime, schools, noise, broadband, and 50+ more filters across all of England.',
upgradePrompt:
'See crime, schools, noise, broadband, and 50+ more filters across all of England.',
oneTimeLifetime: 'One-time payment, lifetime access.',
upgradeToFullMap: 'Upgrade to full map',
chooseFilters: 'Choose the filters that matter to you. The map updates as you go.',
@ -148,7 +148,8 @@ const en = {
// ── Philosophy Popup ───────────────────────────────
philosophy: {
intro: 'Start with your must-haves, then layer on nice-to-haves. The map narrows as you add filters. The areas left are your best matches.',
intro:
'Start with your must-haves, then layer on nice-to-haves. The map narrows as you add filters. The areas left are your best matches.',
step1Title: 'Budget and basics',
step1Desc: '(price range, floor area, property type)',
step2Title: 'Commute',
@ -161,7 +162,7 @@ const en = {
step5Desc: '(restaurants, parks, broadband speed)',
step6Title: 'Energy',
step6Desc: '(EPC ratings, insulation, heating costs)',
tip: "Tip: if nothing matches, relax one constraint at a time to see which trade-off opens up the most options.",
tip: 'Tip: if nothing matches, relax one constraint at a time to see which trade-off opens up the most options.',
},
// ── Travel Time ────────────────────────────────────
@ -171,7 +172,8 @@ const en = {
selectDestination: 'Select destination...',
bestCase: 'Best case',
bestCaseTitle: 'Best case travel time',
bestCaseDesc: 'Uses the fastest realistic journey time (if you time your departure well and catch good connections). The default uses the <strong>median</strong>, representing a typical journey regardless of when you leave.',
bestCaseDesc:
'Uses the fastest realistic journey time (if you time your departure well and catch good connections). The default uses the <strong>median</strong>, representing a typical journey regardless of when you leave.',
previewOnMap: 'Preview on map',
stopPreviewing: 'Stop previewing',
removeTravelTime: 'Remove travel time',
@ -191,7 +193,8 @@ const en = {
// ── Travel Time Info Popup ─────────────────────────
travelInfo: {
transitDesc: ' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.',
transitDesc:
' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.',
carDesc: ' by car, based on typical road speeds and the road network.',
bicycleDesc: ' by bicycle, using cycle-friendly routes.',
walkingDesc: ' on foot, using pedestrian paths and pavements.',
@ -203,24 +206,23 @@ const en = {
aiFilter: {
describeIdealArea: 'Describe your ideal area with AI',
aiSearch: 'AI Search',
describeHint: "describe what you're looking for",
describeHint: "describe what youre looking for",
placeholder: 'e.g. quiet area, under £400k, near good schools...',
example1: 'Safe area near good schools',
example2: '30 min commute to Kings Cross, under £500k',
example3: 'Quiet village, 3 bed, fast broadband',
example1: 'House 40 mins from Bank in a low crime area',
example2: 'Flats around good primary schools not too far from Manchester',
example3: 'Best ex-council houses under 200k',
analysing: 'Analysing your query...',
searchingDestinations: 'Searching for destinations...',
generatingFilters: 'Generating filters...',
refiningResults: 'Refining results...',
weeklyLimitReached: "You've reached the weekly AI usage limit. It will reset automatically next week.",
weeklyLimitReached:
"Youve reached the weekly AI usage limit. It will reset automatically next week.",
},
// ── Map Legend ─────────────────────────────────────
mapLegend: {
clearColourView: 'Clear colour view',
historicalMatches: 'Historical property matches',
propertiesForSale: 'Properties for sale',
propertiesForRent: 'Properties for rent',
numberOfProperties: 'Number of properties',
previewing: 'Previewing \u201c{{name}}\u201d',
},
@ -230,27 +232,23 @@ const en = {
unknownAddress: 'Unknown Address',
unsaveProperty: 'Unsave property',
saveProperty: 'Save property',
lastSold: 'Last sold: £{{price}}',
estValue: 'Est. value:',
type: 'Type:',
builtForm: 'Built form:',
tenure: 'Tenure:',
floorArea: 'Floor area:',
bedrooms: 'Bedrooms:',
bathrooms: 'Bathrooms:',
rooms: 'Rooms:',
built: 'Built:',
formerCouncil: 'Ex-council:',
exCouncilBadge: 'Ex-council',
epcRating: 'EPC rating:',
epcPotential: 'EPC potential:',
listed: 'Listed:',
keyFeatures: 'Key features',
renovations: 'Renovations',
viewExternalListing: 'View external listing',
perMonth: '/mo',
perSqm: '/m²',
searchPlaceholder: 'Search by address or postcode...',
propertyData: 'Property Data',
propertyDataDesc: 'Prices come from HM Land Registry (what buyers actually paid). Floor area, energy ratings, construction year, and tenure come from official EPC surveys. Both sources are matched by address within each postcode.',
propertyDataDesc:
'Prices come from HM Land Registry (what buyers actually paid). Floor area, energy ratings, construction year, and tenure come from official EPC surveys. Both sources are matched by address within each postcode.',
},
// ── Area Pane ──────────────────────────────────────
@ -266,6 +264,7 @@ const en = {
viewOnGoogleMaps: 'View on Google Maps',
walk: 'Walk',
cycle: 'Cycle',
nationalAvg: 'National avg',
},
// ── Histogram Legend ───────────────────────────────
@ -287,7 +286,8 @@ const en = {
poiPane: {
pois: 'POIs',
pointsOfInterest: 'Points of Interest',
poiDescription: 'Sourced from OpenStreetMap. Covers public transport stops, shops, restaurants, healthcare, leisure, and more. Updated regularly with complete category coverage.',
poiDescription:
'Sourced from OpenStreetMap. Covers public transport stops, shops, restaurants, healthcare, leisure, and more. Updated regularly with complete category coverage.',
searchCategories: 'Search categories...',
dataSourceInfo: 'Data source info',
},
@ -295,6 +295,7 @@ const en = {
// ── External Search Links ──────────────────────────
externalSearch: {
searchOn: 'Search {{radius}} on',
exact: 'exact',
outcodeNotRecognised: 'Outcode not recognised',
},
@ -320,7 +321,8 @@ const en = {
heroTitle2: 'Value',
heroTitle3: 'Minimum Compromise.',
heroSubtitle: 'House hunting? Make your biggest investment your smartest move.',
heroDescription: 'So many options - choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that fit.',
heroDescription:
'So many options - choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that fit.',
exploreTheMap: 'Explore the map',
seeTheDifference: 'See the difference',
statProperties: 'properties',
@ -328,8 +330,10 @@ const en = {
statEvery: 'Every',
statPostcodeInEngland: 'postcode in England',
ourPhilosophy: 'Our philosophy',
philosophyP1: "On Rightmove, you pick an area first, then hope it's good. You end up cross-referencing crime stats, school reports, and broadband checkers across a dozen tabs, one postcode at a time.",
philosophyP2: 'We flip that. Tell us what you need (budget, commute, schools, safety) and we show you every area in England that qualifies. No guesswork. No wasted viewings.',
philosophyP1:
"On Rightmove, you pick an area first, then hope its good. You end up cross-referencing crime stats, school reports, and broadband checkers across a dozen tabs, one postcode at a time.",
philosophyP2:
'We flip that. Tell us what you need (budget, commute, schools, safety) and we show you every area in England that qualifies. No guesswork. No wasted viewings.',
howToUseIt: 'How to use it',
howStep1Title: 'Set your must-haves',
howStep1Desc: 'Budget, commute, schools — the map shows only what qualifies.',
@ -338,9 +342,9 @@ const en = {
howStep3Title: 'Drill into postcodes',
howStep3Desc: 'See individual properties, sale prices, floor area, and compare.',
howStep4Title: 'Shortlist with confidence',
howStep4Desc: 'Every area on your list meets your actual criteria — not just what was listed that week.',
howStep4Desc:
'Every area on your list meets your actual criteria — not just what was listed that week.',
othersVs: 'Others vs',
listingPortals: 'Listing portals',
checkMyPostcode: '“Check my postcode”',
areaGuides: 'Area guides',
compSearchWithout: 'Search without choosing an area first',
@ -352,14 +356,15 @@ const en = {
compFilters: '56 combinable filters in one place',
compFiltersSub: '(all insights, one interactive map)',
ctaTitle: 'Make your biggest investment your smartest move.',
ctaDescription: "This deserves proper tools behind it, don't leave it to luck.",
ctaDescription: "This deserves proper tools behind it, dont leave it to luck.",
},
// ── Pricing Page ───────────────────────────────────
pricingPage: {
title: 'Early access pricing',
subtitle: 'Pay once, access forever. The earlier you join, the less you pay.',
costContext: "Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500 for a survey. Get the wrong area and you're stuck with a long commute, bad schools, or a road you didn't know about.",
costContext:
"Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500 for a survey. Get the wrong area and youre stuck with a long commute, bad schools, or a road you didnt know about.",
lessThanSurvey: 'Less than a home survey. Far more useful.',
currentTier: 'Current tier',
firstNUsers: 'First {{count}} users',
@ -390,8 +395,10 @@ const en = {
faq: 'FAQ',
dataSources: 'Data Sources',
support: 'Support',
dataSourcesIntro: 'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
faqIntro: "Whether you're buying, renting, or just exploring, here's how Perfect Postcode helps you find the right area.",
dataSourcesIntro:
'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
faqIntro:
"Whether youre buying, renting, or just exploring, heres how Perfect Postcode helps you find the right area.",
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
source: 'Source:',
optOut: 'Opt out of public disclosure',
@ -411,43 +418,60 @@ const en = {
dsPricePaidUse: 'Complete historical property sale prices for England.',
dsEpcName: 'Energy Performance Certificates (EPC)',
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
dsEpcUse: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction year, energy ratings, property type, and built form. Matched with Price Paid records by address within each postcode. Property owners can opt out of public disclosure.',
dsEpcUse:
'Domestic Energy Performance Certificates providing floor area, number of rooms, construction year, energy ratings, property type, and built form. Matched with Price Paid records by address within each postcode. Property owners can opt out of public disclosure.',
dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
dsNsplOrigin: 'ONS / ArcGIS',
dsNsplUse: 'Maps postcodes to coordinates and statistical area codes, used to link all area-level datasets to individual properties.',
dsNsplUse:
'Maps postcodes to coordinates and statistical area codes, used to link all area-level datasets to individual properties.',
dsIodName: 'English Indices of Deprivation 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse: 'Relative deprivation scores across income, employment, education, health, crime, and living environment for every neighbourhood in England.',
dsIodUse:
'Relative deprivation scores across income, employment, education, health, crime, and living environment for every neighbourhood in England.',
dsEthnicityName: 'Population by Ethnicity (2021 Census)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse: 'Population percentages by ethnic group (South Asian, East Asian, Black, Mixed, White, Other) per local authority.',
dsEthnicityUse:
'Population percentages by ethnic group (South Asian, East Asian, Black, Mixed, White, Other) per local authority.',
dsCrimeName: 'Street-level Crime Data',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse: 'Street-level crime data from 2023 to 2025, aggregated into yearly averages by LSOA and crime type (violence, burglary, anti-social behaviour, drugs, vehicle crime, etc.).',
dsCrimeUse:
'Street-level crime data from 2023 to 2025, aggregated into yearly averages by LSOA and crime type (violence, burglary, anti-social behaviour, drugs, vehicle crime, etc.).',
dsOsmName: 'OpenStreetMap POIs',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse: 'Points of interest covering shops, restaurants, healthcare, leisure, tourism, and more across Great Britain.',
dsOsmUse:
'Points of interest covering shops, restaurants, healthcare, leisure, tourism, and more across Great Britain.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse: 'Authoritative green space boundaries for Great Britain, including public parks, gardens, playing fields, and play spaces. Polygon centroids are used for park proximity counts and distance-to-nearest-park calculations.',
dsGreenspaceUse:
'Authoritative green space boundaries for Great Britain, including public parks, gardens, playing fields, and play spaces. Polygon centroids are used for park proximity counts and distance-to-nearest-park calculations.',
dsNaptanName: 'NaPTAN (Public Transport Stops)',
dsNaptanOrigin: 'Department for Transport',
dsNaptanUse: 'Station and stop locations for rail, bus, metro/tram, ferry, and airports across England.',
dsNaptanUse:
'Station and stop locations for rail, bus, metro/tram, ferry, and airports across England.',
dsNoiseName: 'Defra Noise Mapping',
dsNoiseOrigin: 'Defra / Environment Agency',
dsNoiseUse: 'Road noise levels (24-hour weighted average) from the 2022 strategic noise mapping, modelled at high resolution and sampled at each postcode.',
dsNoiseUse:
'Road noise levels (24-hour weighted average) from the 2022 strategic noise mapping, modelled at high resolution and sampled at each postcode.',
dsOfstedName: 'Ofsted School Inspections',
dsOfstedOrigin: 'Ofsted',
dsOfstedUse: 'Latest inspection outcomes for state-funded schools (as at April 2025). Averaged per postcode to give a local school quality score (1=Outstanding to 4=Inadequate).',
dsOfstedUse:
'Latest inspection outcomes for state-funded schools (as at April 2025). Averaged per postcode to give a local school quality score (1=Outstanding to 4=Inadequate).',
dsBroadbandName: 'Ofcom Broadband Performance',
dsBroadbandOrigin: 'Ofcom',
dsBroadbandUse: 'Fixed broadband coverage and maximum download speeds by area from Ofcom Connected Nations 2025.',
dsBroadbandUse:
'Fixed broadband coverage and maximum download speeds by area from Ofcom Connected Nations 2025.',
dsCouncilTaxName: 'Council Tax Levels 2025-26',
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
dsCouncilTaxUse: 'Annual council tax rates for Bands A-H for all 296 billing authorities in England, for a dwelling occupied by two adults. Joined to properties via local authority district code from the NSPL postcode lookup.',
dsCouncilTaxUse:
'Annual council tax rates for Bands A-H for all 296 billing authorities in England, for a dwelling occupied by two adults. Joined to properties via local authority district code from the NSPL postcode lookup.',
dsRentalName: 'Private Rental Market Statistics',
dsRentalOrigin: 'ONS / Valuation Office Agency',
dsRentalUse: 'Median monthly private rental prices by local authority and bedroom category (Oct 2022 - Sep 2023). Joined to properties via local authority district code and estimated bedroom count.',
dsRentalUse:
'Median monthly private rental prices by local authority and bedroom category (Oct 2022 - Sep 2023). Joined to properties via local authority district code and estimated bedroom count.',
dsElectionName: '2024 General Election Results',
dsElectionOrigin: 'UK Parliament',
dsElectionUse:
'Candidate-level results for the July 2024 UK General Election. Aggregated to constituency level: winning party, voter turnout (%), and majority (%). Joined to properties via the parliamentary constituency code (pcon) from the NSPL postcode lookup.',
// FAQ section titles
faqFindingTitle: 'Finding Your Area',
faqCommuteTitle: 'Commute and Travel',
@ -459,62 +483,87 @@ const en = {
faqPricingTitle: 'Pricing and Access',
faqTipsTitle: 'Tips and Tricks',
// FAQ items — Finding Your Area
faqFinding1Q: "I don't even know which areas to look at. Can this help?",
faqFinding1A: "That's exactly what it's for. Set your filters (budget, commute time, low crime, good schools) and the map lights up to show you every area that ticks every box. No more Googling \"best areas to live near Manchester\" at midnight.",
faqFinding2Q: "I'm moving somewhere I've never been. How do I even start?",
faqFinding2A: "Set your filters for what matters and the map instantly highlights the areas that qualify. You go from \"I don't know a single street\" to a shortlist in minutes.",
faqFinding1Q: "I dont even know which areas to look at. Can this help?",
faqFinding1A:
'That\'s exactly what it\'s for. Set your filters (budget, commute time, low crime, good schools) and the map lights up to show you every area that ticks every box. No more Googling "best areas to live near Manchester" at midnight.',
faqFinding2Q: "Im moving somewhere Ive never been. How do I even start?",
faqFinding2A:
'Set your filters for what matters and the map instantly highlights the areas that qualify. You go from "I don\'t know a single street" to a shortlist in minutes.',
faqFinding3Q: 'How do I find areas that tick all my boxes at once?',
faqFinding3A: 'Stack multiple filters (crime below average, good schools, commute under 40 minutes) then colour the map by price to spot the best value areas. The map updates live as you drag sliders, so you can see results change in real time.',
faqFinding3A:
'Stack multiple filters (crime below average, good schools, commute under 40 minutes) then colour the map by price to spot the best value areas. The map updates live as you drag sliders, so you can see results change in real time.',
// FAQ items — Commute and Travel
faqCommute1Q: 'Can I see how long my commute would actually be from different areas?',
faqCommute1A: "Set your workplace as a destination and we'll colour every postcode by journey time, whether that's by car, bike, or public transport. Filter to your max commute and the rest disappears.",
faqCommute1A:
"Set your workplace as a destination and well colour every postcode by journey time, whether thats by car, bike, or public transport. Filter to your max commute and the rest disappears.",
faqCommute2Q: 'How is that better than checking Google Maps?',
faqCommute2A: 'Google Maps shows you one journey at a time. We colour every postcode in England by commute time in one go, so you can compare hundreds of areas side by side instead of searching them one by one.',
faqCommute2A:
'Google Maps shows you one journey at a time. We colour every postcode in England by commute time in one go, so you can compare hundreds of areas side by side instead of searching them one by one.',
// FAQ items — Budget and Value
faqBudget1Q: 'How do I find areas where I get the most space for my money?',
faqBudget1A: "Filter by price per sqm and you'll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.",
faqBudget2Q: "How do I make sure a cheap area isn't cheap for a reason?",
faqBudget2A: "Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, you've found genuine value, not just a low price with trade-offs you haven't spotted yet.",
faqBudget1A:
"Filter by price per sqm and youll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.",
faqBudget2Q: "How do I make sure a cheap area isnt cheap for a reason?",
faqBudget2A:
"Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, youve found genuine value, not just a low price with trade-offs you havent spotted yet.",
// FAQ items — Safety and Neighbourhood
faqSafety1Q: 'How can I check if an area is safe before I move there?',
faqSafety1A: 'We overlay real police-recorded crime data, broken down by type, onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers.',
faqSafety2Q: 'I keep finding flats that look great online, then the area turns out to be rough.',
faqSafety2A: "That's exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.",
faqSafety1A:
'We overlay real police-recorded crime data, broken down by type, onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers.',
faqSafety2Q:
'I keep finding flats that look great online, then the area turns out to be rough.',
faqSafety2A:
"Thats exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.",
// FAQ items — Families and Schools
faqFamilies1Q: 'Can I find areas with good schools AND low crime in one search?',
faqFamilies1A: "Yes. Stack filters for Ofsted ratings, crime rates, parks, and whatever else matters to your family, and the map highlights only the areas that tick every box. No more cross-referencing five different websites.",
faqFamilies1A:
'Yes. Stack filters for Ofsted ratings, crime rates, parks, and whatever else matters to your family, and the map highlights only the areas that tick every box. No more cross-referencing five different websites.',
faqFamilies2Q: 'How do I know if a neighbourhood has parks and playgrounds nearby?',
faqFamilies2A: 'Toggle on the parks and green spaces POI layer to see them right on the map. You can also filter by how many are within walking distance of each postcode.',
faqFamilies2A:
'Toggle on the parks and green spaces POI layer to see them right on the map. You can also filter by how many are within walking distance of each postcode.',
// FAQ items — Environment and Quality of Life
faqEnv1Q: "Can I find energy-efficient homes that aren't on a noisy road?",
faqEnv1A: 'Filter by EPC rating (A to C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
faqEnv1Q: "Can I find energy-efficient homes that arent on a noisy road?",
faqEnv1A:
'Filter by EPC rating (A to C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
faqEnv2Q: 'Does it show flood or subsidence risk?',
faqEnv2A: "We include ground stability data so you can check for subsidence, shrink-swell clay, and other geological hazards before committing to a property. Filter out risky areas early.",
faqEnv2A:
'We include ground stability data so you can check for subsidence, shrink-swell clay, and other geological hazards before committing to a property. Filter out risky areas early.',
faqEnv3Q: 'Can I find areas with fast broadband that are actually quiet?',
faqEnv3A: 'Layer the broadband speed filter with road noise data to find streets with great connectivity and low traffic noise. Colour-code by either metric to compare areas at a glance.',
faqEnv3A:
'Layer the broadband speed filter with road noise data to find streets with great connectivity and low traffic noise. Colour-code by either metric to compare areas at a glance.',
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'I already use Rightmove. What does this add?',
faqWhy1A: "Rightmove shows you houses. We show you areas. Crime rates, school ratings, broadband speeds, noise levels, deprivation scores, and more, all filterable on one map. You can judge a neighbourhood before you even look at listings.",
faqWhy2Q: "Can't I just research all this myself for free?",
faqWhy2A: 'You could cross-reference police data, Ofsted reports, EPC registers, Land Registry records, and ONS statistics one postcode at a time. Or you could have it all filterable and colour-coded on one map in seconds.',
faqWhy1A:
'Rightmove shows you houses. We show you areas. Crime rates, school ratings, broadband speeds, noise levels, deprivation scores, and more, all filterable on one map. You can judge a neighbourhood before you even look at listings.',
faqWhy2Q: "Cant I just research all this myself for free?",
faqWhy2A:
'You could cross-reference police data, Ofsted reports, EPC registers, Land Registry records, and ONS statistics one postcode at a time. Or you could have it all filterable and colour-coded on one map in seconds.',
faqWhy3Q: 'Where does the data actually come from?',
faqWhy3A: "Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don't scrape estate agents or make anything up. You can verify any record against the original source.",
faqWhy3A:
"Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We dont scrape estate agents or make anything up. You can verify any record against the original source.",
// FAQ items — Pricing and Access
faqPricing1Q: 'Is it really worth paying for a property search tool?',
faqPricing1A: "Buying a home is likely the biggest purchase you'll make. Spotting one red flag (a noisy road, poor broadband, rising crime) before committing could save you years of regret. This costs less than a tank of petrol.",
faqPricing1A:
"Buying a home is likely the biggest purchase youll make. Spotting one red flag (a noisy road, poor broadband, rising crime) before committing could save you years of regret. This costs less than a tank of petrol.",
faqPricing2Q: 'Is this a subscription?',
faqPricing2A: "No. One-time payment, yours forever. Use it intensively during your search, come back whenever you're curious about a new area, and it's still there if you ever move again.",
faqPricing2A:
"No. One-time payment, yours forever. Use it intensively during your search, come back whenever youre curious about a new area, and its still there if you ever move again.",
faqPricing3Q: 'What can I access on the free tier?',
faqPricing3A: 'Free users can explore all features within the demo area (inner London, roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.',
faqPricing3A:
'Free users can explore all features within the demo area (inner London, roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.',
faqPricing4Q: 'Can I get a refund?',
faqPricing4A: 'Absolutely. We offer a 30-day money-back guarantee. If youre not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.',
faqPricing4A:
'Absolutely. We offer a 30-day money-back guarantee. If youre not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.',
// FAQ items — Tips and Tricks
faqTips1Q: 'How do I use the AI filter instead of adding filters one by one?',
faqTips1A: 'Type what you want in plain English, something like "quiet area near good schools with fast broadband under £400k", and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.',
faqTips1A:
'Type what you want in plain English, something like "quiet area near good schools with fast broadband under £400k", and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.',
faqTips2Q: 'Can I save a search and come back to it later?',
faqTips2A: 'Hit the save button and everything is captured: your filters, zoom level, and which data layer youre colouring by. Pick up exactly where you left off or share the link with your partner.',
faqTips3Q: "Can I export the data I'm looking at?",
faqTips3A: 'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.',
faqTips2A:
'Hit the save button and everything is captured: your filters, zoom level, and which data layer youre colouring by. Pick up exactly where you left off or share the link with your partner.',
faqTips3Q: "Can I export the data Im looking at?",
faqTips3A:
'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.',
},
// ── Account Page ───────────────────────────────────
@ -532,17 +581,20 @@ const en = {
savedPage: {
searches: 'Searches',
noSavedSearches: 'No saved searches yet',
noSavedSearchesDesc: 'Save your filters and map view so you can pick up exactly where you left off.',
noSavedSearchesDesc:
'Save your filters and map view so you can pick up exactly where you left off.',
noSavedProperties: 'No saved properties yet',
noSavedPropertiesDesc: 'Bookmark properties as you explore and build your shortlist without losing track.',
noSavedPropertiesDesc:
'Bookmark properties as you explore and build your shortlist without losing track.',
openPostcode: 'Open postcode',
viewListing: 'View listing',
clickToRename: 'Click to rename',
notesPlaceholder: 'Jot down your thoughts...',
deleteSearch: 'Delete search',
deleteSearchConfirm: 'Are you sure you want to delete this saved search? This cannot be undone.',
deleteSearchConfirm:
'Are you sure you want to delete this saved search? This cannot be undone.',
deleteProperty: 'Delete property',
deletePropertyConfirm: 'Are you sure you want to delete this saved property? This cannot be undone.',
deletePropertyConfirm:
'Are you sure you want to delete this saved property? This cannot be undone.',
bed: 'bed',
epc: 'EPC',
},
@ -568,7 +620,7 @@ const en = {
// ── Invite Page ────────────────────────────────────
invitePage: {
youreInvited: "You're invited!",
youreInvited: "Youre invited!",
specialOffer: 'Special offer!',
invitedByFree: '{{name}} has invited you to get free lifetime access.',
invitedByDiscount: '{{name}} has shared a 30% discount on lifetime access.',
@ -621,17 +673,22 @@ const en = {
// ── Tutorial ──────────────────────────────────────
tutorial: {
step1Title: 'Tell the map what matters',
step1Content: 'Set your budget, commute limit, school quality, crime threshold. Whatever matters to you. Only areas that qualify stay lit. Use the eye icon to colour by any feature.',
step1Content:
'Set your budget, commute limit, school quality, crime threshold. Whatever matters to you. Only areas that qualify stay lit. Use the eye icon to colour by any feature.',
step2Title: 'Or just describe it',
step2Content: 'Type what you want in plain English, like "quiet area near good schools under £400k", and well set up the filters for you.',
step2Content:
'Type what you want in plain English, like "quiet area near good schools under £400k", and well set up the filters for you.',
step3Title: 'Explore whats out there',
step3Content: 'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise, and more about that neighbourhood.',
step3Content:
'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise, and more about that neighbourhood.',
step4Title: 'Jump to a location',
step4Content: 'Search for any place or postcode to fly straight there.',
step5Title: 'Dig into the details',
step5Content: 'See area statistics, histograms, and individual property records: prices, floor area, energy ratings, and more.',
step5Content:
'See area statistics, histograms, and individual property records: prices, floor area, energy ratings, and more.',
step6Title: 'Whats nearby?',
step6Content: 'Toggle schools, shops, stations, parks, and restaurants on the map to see whats within reach.',
step6Content:
'Toggle schools, shops, stations, parks, and restaurants on the map to see whats within reach.',
},
// ── Server-derived values ──────────────────────────
@ -639,40 +696,35 @@ const en = {
// The English keys MUST match exactly what the API returns.
server: {
// ─ Feature group names ─
'Properties': 'Properties',
'Transport': 'Transport',
'Education': 'Education',
'Deprivation': 'Deprivation',
'Crime': 'Crime',
'Demographics': 'Demographics',
'Amenities': 'Amenities',
Properties: 'Properties',
Transport: 'Transport',
Education: 'Education',
Deprivation: 'Deprivation',
Crime: 'Crime',
Demographics: 'Demographics',
Politics: 'Politics',
Amenities: 'Amenities',
// ─ Feature names (Properties) ─
'Listing status': 'Listing status',
'Property type': 'Property type',
'Leasehold/Freehold': 'Leasehold/Freehold',
'Last known price': 'Last known price',
'Estimated current price': 'Estimated current price',
'Asking price': 'Asking price',
'Price per sqm': 'Price per sqm',
'Est. price per sqm': 'Est. price per sqm',
'Asking price per sqm': 'Asking price per sqm',
'Estimated monthly rent': 'Estimated monthly rent',
'Asking rent (monthly)': 'Asking rent (monthly)',
'Total floor area (sqm)': 'Total floor area (sqm)',
'Number of bedrooms & living rooms': 'Number of bedrooms & living rooms',
'Bedrooms': 'Bedrooms',
'Bathrooms': 'Bathrooms',
'Construction year': 'Construction year',
'Date of last transaction': 'Date of last transaction',
'Listing date': 'Listing date',
'Former council house': 'Former council house',
'Current energy rating': 'Current energy rating',
'Potential energy rating': 'Potential energy rating',
'Interior height (m)': 'Interior height (m)',
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)': 'Distance to nearest train or tube station (km)',
'Distance to nearest train or tube station (km)':
'Distance to nearest train or tube station (km)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Good+ primary schools within 2km',
@ -718,28 +770,42 @@ const en = {
'% Mixed': '% Mixed',
'% Other': '% Other',
// ─ Feature names (Politics) ─
'Winning party': 'Winning party',
'Voter turnout (%)': 'Voter turnout (%)',
'Majority (%)': 'Majority (%)',
'% Labour': '% Labour',
'% Conservative': '% Conservative',
'% Liberal Democrat': '% Liberal Democrat',
'% Reform UK': '% Reform UK',
'% Green': '% Green',
'% Other parties': '% Other parties',
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Distance to nearest park (km)',
'Number of parks within 2km': 'Number of parks within 2km',
'Number of parks within 1km': 'Number of parks within 1km',
'Number of restaurants within 2km': 'Number of restaurants within 2km',
'Number of grocery shops and supermarkets within 2km': 'Number of grocery shops and supermarkets within 2km',
'Number of grocery shops and supermarkets within 2km':
'Number of grocery shops and supermarkets within 2km',
'Noise (dB)': 'Noise (dB)',
'Max available download speed (Mbps)': 'Max available download speed (Mbps)',
// ─ Enum values ─
'Historical sale': 'Historical sale',
'For sale': 'For sale',
'For rent': 'For rent',
'Detached': 'Detached',
Labour: 'Labour',
Conservative: 'Conservative',
'Liberal Democrat': 'Liberal Democrat',
'Reform UK': 'Reform UK',
Green: 'Green',
'Other parties': 'Other parties',
Detached: 'Detached',
'Semi-Detached': 'Semi-Detached',
'Terraced': 'Terraced',
Terraced: 'Terraced',
'Flats/Maisonettes': 'Flats/Maisonettes',
'Other': 'Other',
'Freehold': 'Freehold',
'Leasehold': 'Leasehold',
'Yes': 'Yes',
'No': 'No',
Other: 'Other',
Freehold: 'Freehold',
Leasehold: 'Leasehold',
Yes: 'Yes',
No: 'No',
// ─ Stacked chart labels ─
'Serious crime': 'Serious crime',
@ -748,52 +814,52 @@ const en = {
// ─ POI group names ─
'Public Transport': 'Public Transport',
'Leisure': 'Leisure',
'Health': 'Health',
Leisure: 'Leisure',
Health: 'Health',
'Emergency Services': 'Emergency Services',
'Groceries': 'Groceries',
Groceries: 'Groceries',
'Local Businesses': 'Local Businesses',
'Culture': 'Culture',
'Services': 'Services',
'Shops': 'Shops',
Culture: 'Culture',
Services: 'Services',
Shops: 'Shops',
// ─ POI categories ─
'Airport': 'Airport',
'Ferry': 'Ferry',
Airport: 'Airport',
Ferry: 'Ferry',
'Rail station': 'Rail station',
'Bus stop': 'Bus stop',
'Bus station': 'Bus station',
'Taxi rank': 'Taxi rank',
'Metro or Tram stop': 'Metro or Tram stop',
'Café': 'Café',
'Restaurant': 'Restaurant',
'Pub': 'Pub',
'Bar': 'Bar',
'Tube station': 'Tube station',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',
Bar: 'Bar',
'Fast Food': 'Fast Food',
'Nightclub': 'Nightclub',
'Cinema': 'Cinema',
'Theatre': 'Theatre',
Nightclub: 'Nightclub',
Cinema: 'Cinema',
Theatre: 'Theatre',
'Live Music & Events': 'Live Music & Events',
'Park': 'Park',
'Playground': 'Playground',
Park: 'Park',
Playground: 'Playground',
'Sports Centre': 'Sports Centre',
'Entertainment': 'Entertainment',
'Supermarket': 'Supermarket',
Entertainment: 'Entertainment',
Supermarket: 'Supermarket',
'Convenience Store': 'Convenience Store',
'Bakery': 'Bakery',
Bakery: 'Bakery',
'Butcher & Fishmonger': 'Butcher & Fishmonger',
'Greengrocer': 'Greengrocer',
Greengrocer: 'Greengrocer',
'Off-Licence': 'Off-Licence',
'Deli & Specialty': 'Deli & Specialty',
'Fashion & Clothing': 'Fashion & Clothing',
'Electronics': 'Electronics',
Electronics: 'Electronics',
'Charity Shop': 'Charity Shop',
'DIY & Hardware': 'DIY & Hardware',
'Home & Garden': 'Home & Garden',
'Bookshop': 'Bookshop',
Bookshop: 'Bookshop',
'Pet Shop': 'Pet Shop',
'Sports & Outdoor': 'Sports & Outdoor',
'Newsagent': 'Newsagent',
Newsagent: 'Newsagent',
'Department Store': 'Department Store',
'Gift & Hobby': 'Gift & Hobby',
'Specialist Shop': 'Specialist Shop',
@ -803,31 +869,31 @@ const en = {
'Car Services': 'Car Services',
'Post Office': 'Post Office',
'Vet & Pet Care': 'Vet & Pet Care',
'Bank': 'Bank',
Bank: 'Bank',
'Travel Agent': 'Travel Agent',
'Police': 'Police',
Police: 'Police',
'Fire Station': 'Fire Station',
'Ambulance Station': 'Ambulance Station',
'GP Surgery': 'GP Surgery',
'Dentist': 'Dentist',
'Pharmacy': 'Pharmacy',
Dentist: 'Dentist',
Pharmacy: 'Pharmacy',
'Hospital & Clinic': 'Hospital & Clinic',
'Optician': 'Optician',
'Physiotherapy': 'Physiotherapy',
Optician: 'Optician',
Physiotherapy: 'Physiotherapy',
'Counselling & Therapy': 'Counselling & Therapy',
'Care Home': 'Care Home',
'Medical & Mobility': 'Medical & Mobility',
'Museum': 'Museum',
'Gallery': 'Gallery',
'Library': 'Library',
Museum: 'Museum',
Gallery: 'Gallery',
Library: 'Library',
'Place of Worship': 'Place of Worship',
'Arts Centre': 'Arts Centre',
'Zoo': 'Zoo',
Zoo: 'Zoo',
'Tourist Attraction': 'Tourist Attraction',
'School': 'School',
'Hotel': 'Hotel',
School: 'School',
Hotel: 'Hotel',
'Local Business': 'Local Business',
'Offices': 'Offices',
Offices: 'Offices',
'EV Charging': 'EV Charging',
'Fuel Station': 'Fuel Station',
'Community Centre': 'Community Centre',
@ -847,7 +913,7 @@ const en = {
export default en;
/**
* Recursively maps a translation object's leaf values to `string`,
* Recursively maps a translation objects leaf values to `string`,
* preserving the nested key structure. Used to type-check non-English
* locale files: they must have exactly the same keys as English,
* but with their own string values.

View file

@ -89,18 +89,17 @@ const fr: Translations = {
// ── Upgrade Modal ──────────────────────────────────
upgrade: {
title: "Découvrez toute l'Angleterre",
title: "Découvrez toute lAngleterre",
description:
"Vous explorez actuellement la zone de démonstration. Obtenez un accès à vie à chaque code postal, chaque filtre, chaque quartier. Un seul paiement, pour toujours.",
'Vous explorez actuellement la zone de démonstration. Obtenez un accès à vie à chaque code postal, chaque filtre, chaque quartier. Un seul paiement, pour toujours.',
free: 'Gratuit',
once: '/unique',
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
oneTimePayment:
'Paiement unique. Accès à vie. Garantie satisfait ou remboursé sous 30 jours.',
oneTimePayment: 'Paiement unique. Accès à vie. Garantie satisfait ou remboursé sous 30 jours.',
redirecting: 'Redirection...',
claimFreeAccess: "Réclamer l'accès gratuit",
claimFreeAccess: "Réclamer laccès gratuit",
upgradeFor: 'Passer à la version complète pour {{price}}',
registerAndUpgrade: "S'inscrire et passer à la version complète",
registerAndUpgrade: "Sinscrire et passer à la version complète",
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
continueWithDemo: 'Continuer avec la démo',
checkoutFailed: 'Échec du paiement',
@ -119,10 +118,10 @@ const fr: Translations = {
// ── License Success ────────────────────────────────
licenseSuccess: {
title: "C'est fait.",
title: "Cest fait.",
subtitle: 'Votre accès à vie est maintenant actif.',
description:
"Accès complet à chaque fonctionnalité, chaque code postal, dans toute l'Angleterre.",
"Accès complet à chaque fonctionnalité, chaque code postal, dans toute lAngleterre.",
startExploring: 'Commencer à explorer',
},
@ -130,14 +129,11 @@ const fr: Translations = {
filters: {
activeFilters: 'Filtres actifs',
addFilter: 'Ajouter un filtre',
historical: 'Historique',
buy: 'Acheter',
rent: 'Louer',
findingPerfectPostcode: 'Trouver le code postal idéal',
addFiltersHint:
'Ajoutez des filtres ci-dessous pour restreindre la carte aux zones correspondant à vos critères',
upgradePrompt:
"Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute l'Angleterre.",
"Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute lAngleterre.",
oneTimeLifetime: 'Paiement unique, accès à vie.',
upgradeToFullMap: 'Passer à la carte complète',
chooseFilters:
@ -159,7 +155,7 @@ const fr: Translations = {
// ── Philosophy Popup ───────────────────────────────
philosophy: {
intro:
"Commencez par vos critères indispensables, puis ajoutez les critères souhaités. La carte se réduit au fur et à mesure que vous ajoutez des filtres. Les zones restantes sont vos meilleures correspondances.",
'Commencez par vos critères indispensables, puis ajoutez les critères souhaités. La carte se réduit au fur et à mesure que vous ajoutez des filtres. Les zones restantes sont vos meilleures correspondances.',
step1Title: 'Budget et fondamentaux',
step1Desc: '(fourchette de prix, surface, type de bien)',
step2Title: 'Trajet',
@ -172,7 +168,7 @@ const fr: Translations = {
step5Desc: '(restaurants, parcs, débit internet)',
step6Title: 'Énergie',
step6Desc: '(classements DPE, isolation, coûts de chauffage)',
tip: "Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus d'options.",
tip: "Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus doptions.",
},
// ── Travel Time ────────────────────────────────────
@ -183,9 +179,9 @@ const fr: Translations = {
bestCase: 'Meilleur cas',
bestCaseTitle: 'Meilleur temps de trajet',
bestCaseDesc:
"Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit l'heure de départ.",
"Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit lheure de départ.",
previewOnMap: 'Aperçu sur la carte',
stopPreviewing: "Arrêter l'aperçu",
stopPreviewing: "Arrêter laperçu",
removeTravelTime: 'Supprimer le temps de trajet',
addTravelTime: 'Ajouter le temps de trajet en {{mode}}',
clearDestination: 'Effacer la destination',
@ -205,14 +201,12 @@ const fr: Translations = {
travelInfo: {
transitDesc:
' en transports en commun (bus, train, métro). Les temps sont calculés sur une fenêtre typique dun matin de semaine.',
carDesc:
' en voiture, basé sur les vitesses de circulation habituelles et le réseau routier.',
carDesc: ' en voiture, basé sur les vitesses de circulation habituelles et le réseau routier.',
bicycleDesc: ' à vélo, via des itinéraires adaptés aux cyclistes.',
walkingDesc: ' à pied, via les chemins piétons et trottoirs.',
mainDesc:
'Affiche le temps nécessaire pour atteindre la destination sélectionnée depuis chaque zone',
sliderHint:
'Utilisez le curseur pour définir votre temps de trajet maximum.',
sliderHint: 'Utilisez le curseur pour définir votre temps de trajet maximum.',
},
// ── AI Filter ──────────────────────────────────────
@ -220,11 +214,10 @@ const fr: Translations = {
describeIdealArea: 'Décrivez votre zone idéale avec lIA',
aiSearch: 'Recherche IA',
describeHint: 'décrivez ce que vous recherchez',
placeholder:
'ex. quartier calme, moins de £400k, près de bonnes écoles...',
example1: 'Quartier sûr près de bonnes écoles',
example2: '30 min de trajet jusquà Kings Cross, moins de £500k',
example3: 'Village tranquille, 3 chambres, débit internet rapide',
placeholder: 'ex. quartier calme, moins de £400k, près de bonnes écoles...',
example1: 'Maison à 40 min de Bank dans un quartier peu criminel',
example2: 'Appartements près de bonnes écoles primaires, pas trop loin de Manchester',
example3: 'Meilleures maisons ex-council à moins de 200k',
analysing: 'Analyse de votre requête...',
searchingDestinations: 'Recherche de destinations...',
generatingFilters: 'Génération des filtres...',
@ -237,8 +230,6 @@ const fr: Translations = {
mapLegend: {
clearColourView: 'Effacer la vue en couleur',
historicalMatches: 'Correspondances immobilières historiques',
propertiesForSale: 'Propriétés à vendre',
propertiesForRent: 'Propriétés à louer',
numberOfProperties: 'Nombre de propriétés',
previewing: 'Aperçu de \u201c{{name}}\u201d',
},
@ -248,23 +239,18 @@ const fr: Translations = {
unknownAddress: 'Adresse inconnue',
unsaveProperty: 'Retirer des favoris',
saveProperty: 'Ajouter aux favoris',
lastSold: 'Dernière vente : £{{price}}',
estValue: 'Valeur estimée :',
type: 'Type :',
builtForm: 'Forme du bâti :',
tenure: 'Régime foncier :',
floorArea: 'Surface :',
bedrooms: 'Chambres :',
bathrooms: 'Salles de bain :',
rooms: 'Pièces :',
built: 'Construction :',
formerCouncil: 'Ancien logement social :',
exCouncilBadge: 'Ex-social',
epcRating: 'Classement DPE :',
epcPotential: 'Potentiel DPE :',
listed: 'Mise en vente :',
keyFeatures: 'Caractéristiques clés',
renovations: 'Rénovations',
viewExternalListing: 'Voir lannonce externe',
perMonth: '/mois',
perSqm: '/m²',
searchPlaceholder: 'Rechercher par adresse ou code postal...',
propertyData: 'Données immobilières',
@ -285,6 +271,7 @@ const fr: Translations = {
viewOnGoogleMaps: 'Voir sur Google Maps',
walk: 'Marche',
cycle: 'Vélo',
nationalAvg: 'Moyenne nationale',
},
// ── Histogram Legend ───────────────────────────────
@ -305,9 +292,9 @@ const fr: Translations = {
// ── POI Pane ───────────────────────────────────────
poiPane: {
pois: 'POI',
pointsOfInterest: "Points d'intérêt",
pointsOfInterest: "Points dintérêt",
poiDescription:
"Données issues d'OpenStreetMap. Couvre les arrêts de transport, commerces, restaurants, établissements de santé, loisirs et plus encore. Mise à jour régulière avec une couverture complète des catégories.",
"Données issues dOpenStreetMap. Couvre les arrêts de transport, commerces, restaurants, établissements de santé, loisirs et plus encore. Mise à jour régulière avec une couverture complète des catégories.",
searchCategories: 'Rechercher des catégories...',
dataSourceInfo: 'Informations sur la source',
},
@ -315,6 +302,7 @@ const fr: Translations = {
// ── External Search Links ──────────────────────────
externalSearch: {
searchOn: 'Rechercher {{radius}} sur',
exact: 'exact',
outcodeNotRecognised: 'Code postal non reconnu',
},
@ -325,7 +313,7 @@ const fr: Translations = {
lookupFailed: 'Échec de la recherche',
searchLabel: 'Rechercher des lieux ou codes postaux',
locateMe: 'Aller à ma position',
geolocationUnsupported: 'La géolocalisation n\'est pas prise en charge par votre navigateur',
geolocationUnsupported: "La géolocalisation nest pas prise en charge par votre navigateur",
geolocationFailed: 'Impossible de déterminer votre position',
},
@ -342,22 +330,21 @@ const fr: Translations = {
heroSubtitle:
'Vous cherchez un bien ? Faites de votre plus gros investissement votre meilleure décision.',
heroDescription:
"Tant d'options — choisir la bonne peut sembler décourageant. Notre carte interactive simplifie tout : sélectionnez vos critères et voyez instantanément les zones qui correspondent.",
"Tant doptions — choisir la bonne peut sembler décourageant. Notre carte interactive simplifie tout : sélectionnez vos critères et voyez instantanément les zones qui correspondent.",
exploreTheMap: 'Explorer la carte',
seeTheDifference: 'Voir la différence',
statProperties: 'propriétés',
statFilters: 'filtres',
statEvery: 'Chaque',
statPostcodeInEngland: "code postal d'Angleterre",
statPostcodeInEngland: "code postal dAngleterre",
ourPhilosophy: 'Notre philosophie',
philosophyP1:
"Sur Rightmove, vous choisissez d'abord une zone, puis vous espérez qu'elle convient. Vous finissez par croiser statistiques de criminalité, rapports scolaires et tests de débit sur une dizaine d'onglets, un code postal à la fois.",
"Sur Rightmove, vous choisissez dabord une zone, puis vous espérez quelle convient. Vous finissez par croiser statistiques de criminalité, rapports scolaires et tests de débit sur une dizaine donglets, un code postal à la fois.",
philosophyP2:
"Nous inversons la logique. Dites-nous ce qu'il vous faut (budget, trajet, écoles, sécurité) et nous vous montrons chaque zone d'Angleterre qui correspond. Plus de devinettes. Plus de visites inutiles.",
"Nous inversons la logique. Dites-nous ce quil vous faut (budget, trajet, écoles, sécurité) et nous vous montrons chaque zone dAngleterre qui correspond. Plus de devinettes. Plus de visites inutiles.",
howToUseIt: 'Comment lutiliser',
howStep1Title: 'Définissez vos indispensables',
howStep1Desc:
'Budget, trajet, écoles — la carte naffiche que ce qui correspond.',
howStep1Desc: 'Budget, trajet, écoles — la carte naffiche que ce qui correspond.',
howStep2Title: 'Explorez les zones et découvrez des pépites cachées',
howStep2Desc: 'Zoomez, examinez les détails et les critères secondaires.',
howStep3Title: 'Plongez dans les codes postaux',
@ -367,31 +354,27 @@ const fr: Translations = {
howStep4Desc:
'Chaque zone de votre liste répond à vos vrais critères — pas seulement à ce qui était en vente cette semaine-là.',
othersVs: 'Les autres vs',
listingPortals: "Portails d'annonces",
checkMyPostcode: '« Vérifier mon code postal »',
areaGuides: 'Guides de quartier',
compSearchWithout: "Rechercher sans d'abord choisir une zone",
compSearchWithoutSub: "(partir de ses besoins, pas d'un lieu)",
compSearchWithout: "Rechercher sans dabord choisir une zone",
compSearchWithoutSub: "(partir de ses besoins, pas dun lieu)",
compAreaData: 'Données de la zone',
compAreaDataSub: '(criminalité, écoles, bruit, débit internet)',
compPropertyData: 'Données par propriété',
compPropertyDataSub: '(prix, DPE, surface)',
compFilters: '56 filtres combinables en un seul endroit',
compFiltersSub: '(toutes les informations, une seule carte interactive)',
ctaTitle:
'Faites de votre plus gros investissement votre meilleure décision.',
ctaDescription:
'Un tel enjeu mérite de vrais outils, ne laissez pas la chance décider.',
ctaTitle: 'Faites de votre plus gros investissement votre meilleure décision.',
ctaDescription: 'Un tel enjeu mérite de vrais outils, ne laissez pas la chance décider.',
},
// ── Pricing Page ───────────────────────────────────
pricingPage: {
title: 'Tarifs early access',
subtitle:
"Payez une fois, accédez pour toujours. Plus vous rejoignez tôt, moins vous payez.",
subtitle: 'Payez une fois, accédez pour toujours. Plus vous rejoignez tôt, moins vous payez.',
costContext:
"L'achat d'un bien coûte plus de £10 000 en droits de mutation, £1 500 en frais de notaire, £500 pour une expertise. Choisissez le mauvais quartier et vous vous retrouvez avec un long trajet, de mauvaises écoles ou une route dont vous ignoriez l'existence.",
lessThanSurvey: "Moins cher qu'une expertise immobilière. Bien plus utile.",
"Lachat dun bien coûte plus de £10 000 en droits de mutation, £1 500 en frais de notaire, £500 pour une expertise. Choisissez le mauvais quartier et vous vous retrouvez avec un long trajet, de mauvaises écoles ou une route dont vous ignoriez lexistence.",
lessThanSurvey: "Moins cher quune expertise immobilière. Bien plus utile.",
currentTier: 'Palier actuel',
firstNUsers: '{{count}} premiers utilisateurs',
everyoneAfter: 'Tous les suivants',
@ -407,9 +390,8 @@ const fr: Translations = {
moneyBackGuarantee: 'Garantie satisfait ou remboursé sous 30 jours',
soldOut: 'Épuisé',
upcoming: 'À venir',
failedToLoad:
'Échec du chargement des tarifs. Veuillez réessayer plus tard.',
feat1: "56 couches de données à travers l'Angleterre",
failedToLoad: 'Échec du chargement des tarifs. Veuillez réessayer plus tard.',
feat1: "56 couches de données à travers lAngleterre",
feat2: 'Chaque code postal noté et filtrable',
feat3: 'Exploration de la carte et exportations illimitées',
feat4: 'Plusieurs décennies de données historiques de prix',
@ -422,13 +404,16 @@ const fr: Translations = {
faq: 'FAQ',
dataSources: 'Sources de données',
support: 'Assistance',
dataSourcesIntro: 'Cette application combine {{count}} jeux de données ouverts couvrant les prix immobiliers, la performance énergétique, les transports, la démographie, la criminalité, lenvironnement et plus encore.',
faqIntro: 'Que vous achetiez, louiez ou exploriez simplement, voici comment Perfect Postcode vous aide à trouver le bon quartier.',
dataSourcesIntro:
'Cette application combine {{count}} jeux de données ouverts couvrant les prix immobiliers, la performance énergétique, les transports, la démographie, la criminalité, lenvironnement et plus encore.',
faqIntro:
'Que vous achetiez, louiez ou exploriez simplement, voici comment Perfect Postcode vous aide à trouver le bon quartier.',
supportIntro: 'Vous avez une question ? Consultez notre FAQ ou contactez-nous directement.',
source: 'Source :',
optOut: 'Retrait de la divulgation publique',
attribution: 'Attribution',
attrLandRegistry: 'Contient des données du HM Land Registry © Crown copyright and database right 2025.',
attrLandRegistry:
'Contient des données du HM Land Registry © Crown copyright and database right 2025.',
attrOgl: 'Contient des informations du secteur public sous licence',
attrOglLink: 'Open Government Licence v3.0',
attrOs: 'Contient des données OS © Crown copyright and database rights 2025.',
@ -443,43 +428,60 @@ const fr: Translations = {
dsPricePaidUse: 'Historique complet des prix de vente immobiliers en Angleterre.',
dsEpcName: 'Energy Performance Certificates (EPC)',
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
dsEpcUse: 'Certificats de performance énergétique domestiques fournissant la surface, le nombre de pièces, lannée de construction, les classements énergétiques, le type de bien et la forme du bâti. Associés aux données Price Paid par adresse au sein de chaque code postal. Les propriétaires peuvent demander le retrait de la divulgation publique.',
dsEpcUse:
'Certificats de performance énergétique domestiques fournissant la surface, le nombre de pièces, lannée de construction, les classements énergétiques, le type de bien et la forme du bâti. Associés aux données Price Paid par adresse au sein de chaque code postal. Les propriétaires peuvent demander le retrait de la divulgation publique.',
dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
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.',
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: 'English Indices of Deprivation 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse: 'Scores de défaveur relative couvrant le revenu, lemploi, léducation, la santé, la criminalité et le cadre de vie pour chaque quartier dAngleterre.',
dsIodUse:
'Scores de défaveur relative 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: 'Pourcentages de population par groupe ethnique (sud-asiatique, est-asiatique, noir, mixte, blanc, autre) par autorité locale.',
dsEthnicityUse:
'Pourcentages de population par groupe ethnique (sud-asiatique, est-asiatique, noir, mixte, blanc, autre) par autorité locale.',
dsCrimeName: 'Street-level Crime Data',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse: 'Données de criminalité de proximité de 2023 à 2025, agrégées en moyennes annuelles par LSOA et type dinfraction (violences, cambriolages, troubles à lordre public, stupéfiants, vols de véhicules, etc.).',
dsCrimeUse:
'Données de criminalité de proximité de 2023 à 2025, agrégées en moyennes annuelles par LSOA et type dinfraction (violences, cambriolages, troubles à lordre public, stupéfiants, vols de véhicules, etc.).',
dsOsmName: 'OpenStreetMap POIs',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse: 'Points dintérêt couvrant commerces, restaurants, santé, loisirs, tourisme et plus à travers la Grande-Bretagne.',
dsOsmUse:
'Points dintérêt couvrant commerces, restaurants, santé, loisirs, tourisme et plus à travers la Grande-Bretagne.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse: 'Limites officielles des espaces verts de Grande-Bretagne, incluant parcs publics, jardins, terrains de sport et aires de jeux. Les centroïdes des polygones sont utilisés pour le comptage de proximité des parcs et le calcul de la distance au parc le plus proche.',
dsGreenspaceUse:
'Limites officielles des espaces verts de Grande-Bretagne, incluant parcs publics, jardins, terrains de sport et aires de jeux. Les centroïdes des polygones sont utilisés pour le comptage de proximité des parcs et le calcul de la distance au parc le plus proche.',
dsNaptanName: 'NaPTAN (Public Transport Stops)',
dsNaptanOrigin: 'Department for Transport',
dsNaptanUse: 'Emplacements des gares et arrêts pour le rail, le bus, le métro/tramway, le ferry et les aéroports à travers lAngleterre.',
dsNaptanUse:
'Emplacements des gares et arrêts pour le rail, le bus, le métro/tramway, le ferry et les aéroports à travers lAngleterre.',
dsNoiseName: 'Defra Noise Mapping',
dsNoiseOrigin: 'Defra / Environment Agency',
dsNoiseUse: 'Niveaux de bruit routier (moyenne pondérée sur 24 heures) issus de la cartographie stratégique du bruit de 2022, modélisés à haute résolution et échantillonnés à chaque code postal.',
dsNoiseUse:
'Niveaux de bruit routier (moyenne pondérée sur 24 heures) issus de la cartographie stratégique du bruit de 2022, modélisés à haute résolution et échantillonnés à chaque code postal.',
dsOfstedName: 'Ofsted School Inspections',
dsOfstedOrigin: 'Ofsted',
dsOfstedUse: 'Derniers résultats dinspection des écoles publiques (avril 2025). Moyennés par code postal pour donner un score de qualité scolaire local (1=Excellent à 4=Insuffisant).',
dsOfstedUse:
'Derniers résultats dinspection des écoles publiques (avril 2025). Moyennés par code postal pour donner un score de qualité scolaire local (1=Excellent à 4=Insuffisant).',
dsBroadbandName: 'Ofcom Broadband Performance',
dsBroadbandOrigin: 'Ofcom',
dsBroadbandUse: 'Couverture haut débit fixe et débits de téléchargement maximum par zone, issus de Ofcom Connected Nations 2025.',
dsBroadbandUse:
'Couverture haut débit fixe et débits de téléchargement maximum par zone, issus de Ofcom Connected Nations 2025.',
dsCouncilTaxName: 'Council Tax Levels 2025-26',
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
dsCouncilTaxUse: 'Taux annuels de taxe dhabitation pour les tranches A à H pour les 296 autorités de facturation dAngleterre, pour un logement occupé par deux adultes. Reliés aux propriétés via le code dautorité locale du répertoire de codes postaux NSPL.',
dsCouncilTaxUse:
'Taux annuels de taxe dhabitation pour les tranches A à H pour les 296 autorités de facturation dAngleterre, pour un logement occupé par deux adultes. Reliés aux propriétés via le code dautorité locale du répertoire de codes postaux NSPL.',
dsRentalName: 'Private Rental Market Statistics',
dsRentalOrigin: 'ONS / Valuation Office Agency',
dsRentalUse: 'Loyers mensuels médians du marché locatif privé par autorité locale et catégorie de chambres (oct. 2022 - sept. 2023). Reliés aux propriétés via le code dautorité locale et le nombre estimé de chambres.',
dsRentalUse:
'Loyers mensuels médians du marché locatif privé par autorité locale et catégorie de chambres (oct. 2022 - sept. 2023). Reliés aux propriétés via le code dautorité locale et le nombre estimé de chambres.',
dsElectionName: 'Résultats des élections générales 2024',
dsElectionOrigin: 'Parlement britannique',
dsElectionUse:
'Résultats par candidat des élections générales britanniques de juillet 2024. Agrégés au niveau de la circonscription : parti vainqueur, participation électorale (%) et majorité (%). Reliés aux propriétés via le code de circonscription parlementaire (pcon) du répertoire de codes postaux NSPL.',
// FAQ section titles
faqFindingTitle: 'Trouver votre quartier',
faqCommuteTitle: 'Trajet et déplacements',
@ -492,61 +494,90 @@ const fr: Translations = {
faqTipsTitle: 'Astuces',
// FAQ items — Finding Your Area
faqFinding1Q: 'Je ne sais même pas quelles zones regarder. Est-ce que ça peut maider ?',
faqFinding1A: 'Cest exactement à ça que ça sert. Définissez vos filtres (budget, temps de trajet, faible criminalité, bonnes écoles) et la carte sillumine pour montrer chaque zone qui coche toutes les cases. Fini de chercher « meilleures zones pour vivre près de Manchester » à minuit.',
faqFinding1A:
'Cest exactement à ça que ça sert. Définissez vos filtres (budget, temps de trajet, faible criminalité, bonnes écoles) et la carte sillumine pour montrer chaque zone qui coche toutes les cases. Fini de chercher « meilleures zones pour vivre près de Manchester » à minuit.',
faqFinding2Q: 'Je déménage dans un endroit que je ne connais pas du tout. Par où commencer ?',
faqFinding2A: 'Définissez vos filtres pour ce qui compte et la carte met instantanément en évidence les zones qui correspondent. Vous passez de « je ne connais pas une seule rue » à une sélection en quelques minutes.',
faqFinding2A:
'Définissez vos filtres pour ce qui compte et la carte met instantanément en évidence les zones qui correspondent. Vous passez de « je ne connais pas une seule rue » à une sélection en quelques minutes.',
faqFinding3Q: 'Comment trouver des zones qui cochent toutes mes cases en une seule fois ?',
faqFinding3A: 'Empilez plusieurs filtres (criminalité sous la moyenne, bonnes écoles, trajet de moins de 40 minutes) puis colorez la carte par prix pour repérer les zones au meilleur rapport qualité-prix. La carte se met à jour en temps réel quand vous bougez les curseurs.',
faqFinding3A:
'Empilez plusieurs filtres (criminalité sous la moyenne, bonnes écoles, trajet de moins de 40 minutes) puis colorez la carte par prix pour repérer les zones au meilleur rapport qualité-prix. La carte se met à jour en temps réel quand vous bougez les curseurs.',
// FAQ items — Commute and Travel
faqCommute1Q: 'Puis-je voir combien de temps durerait mon trajet depuis différentes zones ?',
faqCommute1A: 'Définissez votre lieu de travail comme destination et nous colorons chaque code postal par temps de trajet, que ce soit en voiture, à vélo ou en transports en commun. Filtrez par votre trajet maximum et le reste disparaît.',
faqCommute1A:
'Définissez votre lieu de travail comme destination et nous colorons chaque code postal par temps de trajet, que ce soit en voiture, à vélo ou en transports en commun. Filtrez par votre trajet maximum et le reste disparaît.',
faqCommute2Q: 'En quoi cest mieux que Google Maps ?',
faqCommute2A: 'Google Maps vous montre un trajet à la fois. Nous colorons chaque code postal dAngleterre par temps de trajet en une seule vue, pour que vous puissiez comparer des centaines de zones côte à côte au lieu de les chercher une par une.',
faqCommute2A:
'Google Maps vous montre un trajet à la fois. Nous colorons chaque code postal dAngleterre par temps de trajet en une seule vue, pour que vous puissiez comparer des centaines de zones côte à côte au lieu de les chercher une par une.',
// FAQ items — Budget and Value
faqBudget1Q: 'Comment trouver les zones où jai le plus despace pour mon argent ?',
faqBudget1A: 'Filtrez par prix au m² et vous verrez instantanément quels codes postaux offrent le plus despace par livre. Combinez avec le filtre de classement énergétique pour éviter les biens aux coûts de chauffage élevés.',
faqBudget2Q: 'Comment massurer quune zone bon marché ne lest pas pour de mauvaises raisons ?',
faqBudget2A: 'Superposez les scores de défaveur, les statistiques de criminalité, les notes des écoles et les débits internet à côté du prix. Si un code postal est abordable et obtient de bons scores sur tout ce qui compte, vous avez trouvé une vraie bonne affaire, pas juste un prix bas avec des compromis que vous navez pas encore repérés.',
faqBudget1A:
'Filtrez par prix au m² et vous verrez instantanément quels codes postaux offrent le plus despace par livre. Combinez avec le filtre de classement énergétique pour éviter les biens aux coûts de chauffage élevés.',
faqBudget2Q:
'Comment massurer quune zone bon marché ne lest pas pour de mauvaises raisons ?',
faqBudget2A:
'Superposez les scores de défaveur, les statistiques de criminalité, les notes des écoles et les débits internet à côté du prix. Si un code postal est abordable et obtient de bons scores sur tout ce qui compte, vous avez trouvé une vraie bonne affaire, pas juste un prix bas avec des compromis que vous navez pas encore repérés.',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: 'Comment vérifier si une zone est sûre avant dy déménager ?',
faqSafety1A: 'Nous superposons les données réelles de criminalité enregistrées par la police, ventilées par type, sur chaque quartier dAngleterre. Filtrez par criminalité violente, cambriolages ou troubles à lordre public et voyez instantanément quels codes postaux ont les chiffres les plus bas.',
faqSafety2Q: 'Je trouve sans cesse des appartements superbes en ligne, puis le quartier savère difficile.',
faqSafety2A: 'Cest exactement pour ça que cet outil existe. Empilez taux de criminalité, niveaux de bruit, scores de défaveur, pubs et parcs à proximité, et débits internet, le tout sur une seule carte, pour savoir à quoi ressemble vraiment un quartier avant de réserver une visite.',
faqSafety1A:
'Nous superposons les données réelles de criminalité enregistrées par la police, ventilées par type, sur chaque quartier dAngleterre. Filtrez par criminalité violente, cambriolages ou troubles à lordre public et voyez instantanément quels codes postaux ont les chiffres les plus bas.',
faqSafety2Q:
'Je trouve sans cesse des appartements superbes en ligne, puis le quartier savère difficile.',
faqSafety2A:
'Cest exactement pour ça que cet outil existe. Empilez taux de criminalité, niveaux de bruit, scores de défaveur, pubs et parcs à proximité, et débits internet, le tout sur une seule carte, pour savoir à quoi ressemble vraiment un quartier avant de réserver une visite.',
// FAQ items — Families and Schools
faqFamilies1Q: 'Puis-je trouver des zones avec de bonnes écoles ET peu de criminalité en une seule recherche ?',
faqFamilies1A: 'Oui. Empilez les filtres pour les notes Ofsted, les taux de criminalité, les parcs et tout ce qui compte pour votre famille, et la carte ne met en évidence que les zones qui cochent toutes les cases. Fini de croiser cinq sites différents.',
faqFamilies1Q:
'Puis-je trouver des zones avec de bonnes écoles ET peu de criminalité en une seule recherche ?',
faqFamilies1A:
'Oui. Empilez les filtres pour les notes Ofsted, les taux de criminalité, les parcs et tout ce qui compte pour votre famille, et la carte ne met en évidence que les zones qui cochent toutes les cases. Fini de croiser cinq sites différents.',
faqFamilies2Q: 'Comment savoir si un quartier a des parcs et des aires de jeux à proximité ?',
faqFamilies2A: 'Activez la couche de POI parcs et espaces verts pour les voir directement sur la carte. Vous pouvez aussi filtrer par le nombre de parcs accessibles à pied depuis chaque code postal.',
faqFamilies2A:
'Activez la couche de POI parcs et espaces verts pour les voir directement sur la carte. Vous pouvez aussi filtrer par le nombre de parcs accessibles à pied depuis chaque code postal.',
// FAQ items — Environment and Quality of Life
faqEnv1Q: 'Puis-je trouver des logements économes en énergie qui ne sont pas sur une route bruyante ?',
faqEnv1A: 'Filtrez par classement EPC (A à C), puis superposez les données de bruit routier pour exclure tout ce qui dépasse votre seuil. Colorez par lun ou lautre critère pour repérer les rues calmes et économes dun coup dœil.',
faqEnv1Q:
'Puis-je trouver des logements économes en énergie qui ne sont pas sur une route bruyante ?',
faqEnv1A:
'Filtrez par classement EPC (A à C), puis superposez les données de bruit routier pour exclure tout ce qui dépasse votre seuil. Colorez par lun ou lautre critère pour repérer les rues calmes et économes dun coup dœil.',
faqEnv2Q: 'Est-ce que ça montre le risque dinondation ou daffaissement ?',
faqEnv2A: 'Nous incluons des données de stabilité du sol pour que vous puissiez vérifier les risques daffaissement, de retrait-gonflement des argiles et dautres aléas géologiques avant de vous engager. Excluez les zones à risque dès le départ.',
faqEnv2A:
'Nous incluons des données de stabilité du sol pour que vous puissiez vérifier les risques daffaissement, de retrait-gonflement des argiles et dautres aléas géologiques avant de vous engager. Excluez les zones à risque dès le départ.',
faqEnv3Q: 'Puis-je trouver des zones avec un bon débit internet qui soient aussi calmes ?',
faqEnv3A: 'Superposez le filtre de débit internet avec les données de bruit routier pour trouver des rues avec une bonne connectivité et peu de bruit. Colorez par lun ou lautre critère pour comparer les zones dun coup dœil.',
faqEnv3A:
'Superposez le filtre de débit internet avec les données de bruit routier pour trouver des rues avec une bonne connectivité et peu de bruit. Colorez par lun ou lautre critère pour comparer les zones dun coup dœil.',
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'Jutilise déjà Rightmove. Quest-ce que ça apporte de plus ?',
faqWhy1A: 'Rightmove vous montre des maisons. Nous vous montrons des quartiers. Taux de criminalité, notes des écoles, débits internet, niveaux de bruit, scores de défaveur et plus, tout filtrable sur une seule carte. Vous pouvez juger un quartier avant même de regarder les annonces.',
faqWhy1A:
'Rightmove vous montre des maisons. Nous vous montrons des quartiers. Taux de criminalité, notes des écoles, débits internet, niveaux de bruit, scores de défaveur et plus, tout filtrable sur une seule carte. Vous pouvez juger un quartier avant même de regarder les annonces.',
faqWhy2Q: 'Je ne peux pas simplement faire ces recherches gratuitement moi-même ?',
faqWhy2A: 'Vous pourriez croiser les données policières, les rapports Ofsted, les registres EPC, les archives du Land Registry et les statistiques ONS un code postal à la fois. Ou vous pouvez avoir le tout filtrable et coloré sur une seule carte en quelques secondes.',
faqWhy2A:
'Vous pourriez croiser les données policières, les rapports Ofsted, les registres EPC, les archives du Land Registry et les statistiques ONS un code postal à la fois. Ou vous pouvez avoir le tout filtrable et coloré sur une seule carte en quelques secondes.',
faqWhy3Q: 'Doù viennent réellement les données ?',
faqWhy3A: 'Chaque jeu de données provient de sources officielles du gouvernement britannique : Land Registry, le registre EPC, ONS, Ofsted, Ofcom, data.police.uk et Defra. Nous ne scrapons pas les agents immobiliers et ninventons rien. Vous pouvez vérifier chaque donnée auprès de la source originale.',
faqWhy3A:
'Chaque jeu de données provient de sources officielles du gouvernement britannique : Land Registry, le registre EPC, ONS, Ofsted, Ofcom, data.police.uk et Defra. Nous ne scrapons pas les agents immobiliers et ninventons rien. Vous pouvez vérifier chaque donnée auprès de la source originale.',
// FAQ items — Pricing and Access
faqPricing1Q: 'Est-ce que ça vaut vraiment le coup de payer pour un outil de recherche immobilière ?',
faqPricing1A: 'Lachat dun logement est probablement le plus gros achat de votre vie. Repérer un seul signal dalerte (une route bruyante, un mauvais débit, une criminalité en hausse) avant de vous engager pourrait vous épargner des années de regrets. Ça coûte moins quun plein dessence.',
faqPricing1Q:
'Est-ce que ça vaut vraiment le coup de payer pour un outil de recherche immobilière ?',
faqPricing1A:
'Lachat dun logement est probablement le plus gros achat de votre vie. Repérer un seul signal dalerte (une route bruyante, un mauvais débit, une criminalité en hausse) avant de vous engager pourrait vous épargner des années de regrets. Ça coûte moins quun plein dessence.',
faqPricing2Q: 'Est-ce un abonnement ?',
faqPricing2A: 'Non. Paiement unique, à vous pour toujours. Utilisez-le intensivement pendant votre recherche, revenez quand vous êtes curieux dune nouvelle zone, et cest toujours là si vous déménagez à nouveau.',
faqPricing2A:
'Non. Paiement unique, à vous pour toujours. Utilisez-le intensivement pendant votre recherche, revenez quand vous êtes curieux dune nouvelle zone, et cest toujours là si vous déménagez à nouveau.',
faqPricing3Q: 'Que puis-je faire avec la version gratuite ?',
faqPricing3A: 'Les utilisateurs gratuits peuvent explorer toutes les fonctionnalités dans la zone de démonstration (centre de Londres, approximativement zones 1 à 2). Pour accéder aux données du reste de lAngleterre, il faut laccès à vie.',
faqPricing3A:
'Les utilisateurs gratuits peuvent explorer toutes les fonctionnalités dans la zone de démonstration (centre de Londres, approximativement zones 1 à 2). Pour accéder aux données du reste de lAngleterre, il faut laccès à vie.',
faqPricing4Q: 'Puis-je obtenir un remboursement ?',
faqPricing4A: 'Absolument. Nous offrons une garantie satisfait ou remboursé sous 30 jours. Si vous nêtes pas satisfait, envoyez un e-mail à support@perfect-postcode.co.uk dans les 30 jours pour un remboursement intégral.',
faqPricing4A:
'Absolument. Nous offrons une garantie satisfait ou remboursé sous 30 jours. Si vous nêtes pas satisfait, envoyez un e-mail à support@perfect-postcode.co.uk dans les 30 jours pour un remboursement intégral.',
// FAQ items — Tips and Tricks
faqTips1Q: 'Comment utiliser le filtre IA au lieu dajouter les filtres un par un ?',
faqTips1A: 'Tapez ce que vous voulez en langage courant, par exemple « quartier calme près de bonnes écoles avec bon débit internet à moins de £400k », et il configurera tous les filtres pertinents dun coup. Ajustez ensuite manuellement si nécessaire.',
faqTips1A:
'Tapez ce que vous voulez en langage courant, par exemple « quartier calme près de bonnes écoles avec bon débit internet à moins de £400k », et il configurera tous les filtres pertinents dun coup. Ajustez ensuite manuellement si nécessaire.',
faqTips2Q: 'Puis-je enregistrer une recherche et y revenir plus tard ?',
faqTips2A: 'Cliquez sur le bouton denregistrement et tout est capturé : vos filtres, le niveau de zoom et la couche de données affichée. Reprenez exactement où vous en étiez ou partagez le lien avec votre conjoint.',
faqTips2A:
'Cliquez sur le bouton denregistrement et tout est capturé : vos filtres, le niveau de zoom et la couche de données affichée. Reprenez exactement où vous en étiez ou partagez le lien avec votre conjoint.',
faqTips3Q: 'Puis-je exporter les données que je consulte ?',
faqTips3A: 'Utilisez le bouton dexportation pour télécharger les propriétés filtrées sous forme de tableur. Lexport respecte tous vos filtres actifs, vous obtenez donc exactement les données souhaitées.',
faqTips3A:
'Utilisez le bouton dexportation pour télécharger les propriétés filtrées sous forme de tableur. Lexport respecte tous vos filtres actifs, vous obtenez donc exactement les données souhaitées.',
},
// ── Account Page ───────────────────────────────────
@ -570,7 +601,6 @@ const fr: Translations = {
noSavedPropertiesDesc:
'Ajoutez des propriétés en favoris au fil de votre exploration et constituez votre sélection sans rien perdre de vue.',
openPostcode: 'Ouvrir le code postal',
viewListing: 'Voir lannonce',
clickToRename: 'Cliquez pour renommer',
notesPlaceholder: 'Notez vos impressions...',
deleteSearch: 'Supprimer la recherche',
@ -585,17 +615,16 @@ const fr: Translations = {
// ── Invites Page ───────────────────────────────────
invitesPage: {
inviteLinksLicensed:
"Les liens d'invitation sont disponibles pour les utilisateurs licenciés.",
inviteLinksLicensed: "Les liens dinvitation sont disponibles pour les utilisateurs licenciés.",
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
generateFreeInvite: "Générer un lien d'invitation gratuit",
generateFreeInvite: "Générer un lien dinvitation gratuit",
generateReferralLink: 'Générer un lien de parrainage',
copyInviteLink: "Copier le lien d'invitation",
copyInviteLink: "Copier le lien dinvitation",
adminInvitesTitle: 'Invitations admin (100% de réduction)',
referralInvitesTitle: 'Invitations de parrainage (30% de réduction)',
yourInviteLinks: "Vos liens d'invitation",
noInvitesYet: "Aucune invitation générée pour l'instant",
yourInviteLinks: "Vos liens dinvitation",
noInvitesYet: "Aucune invitation générée pour linstant",
link: 'Lien',
status: 'Statut',
created: 'Créé',
@ -607,33 +636,27 @@ const fr: Translations = {
invitePage: {
youreInvited: 'Vous êtes invité !',
specialOffer: 'Offre spéciale !',
invitedByFree:
'{{name}} vous invite à obtenir un accès à vie gratuit.',
invitedByDiscount:
"{{name}} vous fait bénéficier d'une réduction de 30% sur l'accès à vie.",
genericFreeInvite:
'Vous avez été invité à obtenir un accès à vie gratuit.',
genericDiscount:
"Un ami vous fait bénéficier d'une réduction de 30% sur l'accès à vie.",
exploreEvery: "Explorez chaque quartier d'Angleterre",
invitedByFree: '{{name}} vous invite à obtenir un accès à vie gratuit.',
invitedByDiscount: "{{name}} vous fait bénéficier dune réduction de 30% sur laccès à vie.",
genericFreeInvite: 'Vous avez été invité à obtenir un accès à vie gratuit.',
genericDiscount: "Un ami vous fait bénéficier dune réduction de 30% sur laccès à vie.",
exploreEvery: "Explorez chaque quartier dAngleterre",
propertyInfo:
"Prix immobiliers, classements énergétiques, statistiques de criminalité, notes des écoles et plus encore",
'Prix immobiliers, classements énergétiques, statistiques de criminalité, notes des écoles et plus encore',
invalidInvite: 'Invitation invalide',
inviteAlreadyUsed: 'Invitation déjà utilisée',
inviteAlreadyUsedDesc: "Ce lien d'invitation a déjà été utilisé.",
invalidInviteLink: "Lien d'invitation invalide",
invalidInviteLinkDesc:
"Ce lien d'invitation est invalide ou a expiré.",
inviteAlreadyUsedDesc: "Ce lien dinvitation a déjà été utilisé.",
invalidInviteLink: "Lien dinvitation invalide",
invalidInviteLinkDesc: "Ce lien dinvitation est invalide ou a expiré.",
licenseActivated: 'Licence activée !',
fullAccessGranted:
'Vous avez désormais un accès complet à Perfect Postcode.',
fullAccessGranted: 'Vous avez désormais un accès complet à Perfect Postcode.',
activating: 'Activation...',
activateLicense: 'Activer la licence',
claimDiscount: 'Réclamer la réduction',
registerToClaim: "S'inscrire pour réclamer",
registerToClaim: "Sinscrire pour réclamer",
youAlreadyHaveLicense: 'Vous avez déjà une licence',
accountHasFullAccess: 'Votre compte dispose déjà dun accès complet.',
failedToValidate: "Échec de la validation du lien d'invitation",
failedToValidate: "Échec de la validation du lien dinvitation",
},
// ── Map Page ───────────────────────────────────────
@ -665,17 +688,23 @@ const fr: Translations = {
// ── Tutorial ──────────────────────────────────────
tutorial: {
step1Title: 'Dites à la carte ce qui compte',
step1Content: 'Définissez votre budget, temps de trajet maximum, qualité des écoles, seuil de criminalité. Ce qui compte pour vous. Seules les zones qui correspondent restent éclairées. Utilisez licône œil pour colorier par nimporte quel critère.',
step1Content:
'Définissez votre budget, temps de trajet maximum, qualité des écoles, seuil de criminalité. Ce qui compte pour vous. Seules les zones qui correspondent restent éclairées. Utilisez licône œil pour colorier par nimporte quel critère.',
step2Title: 'Ou décrivez simplement',
step2Content: 'Tapez ce que vous voulez en français, par exemple « quartier calme près de bonnes écoles sous £400k », et nous configurerons les filtres pour vous.',
step2Content:
'Tapez ce que vous voulez en français, par exemple « quartier calme près de bonnes écoles sous £400k », et nous configurerons les filtres pour vous.',
step3Title: 'Explorez ce qui existe',
step3Content: 'Naviguez et zoomez à travers lAngleterre. Cliquez sur nimporte quelle zone colorée pour voir la criminalité, les écoles, les prix, le haut débit, le bruit et plus encore.',
step3Content:
'Naviguez et zoomez à travers lAngleterre. Cliquez sur nimporte quelle zone colorée pour voir la criminalité, les écoles, les prix, le haut débit, le bruit et plus encore.',
step4Title: 'Allez directement à un lieu',
step4Content: 'Recherchez nimporte quel lieu ou code postal pour vous y rendre instantanément.',
step4Content:
'Recherchez nimporte quel lieu ou code postal pour vous y rendre instantanément.',
step5Title: 'Examinez les détails',
step5Content: 'Consultez les statistiques de zone, histogrammes et fiches individuelles : prix, surface, performances énergétiques et plus.',
step5Content:
'Consultez les statistiques de zone, histogrammes et fiches individuelles : prix, surface, performances énergétiques et plus.',
step6Title: 'Quy a-t-il à proximité ?',
step6Content: 'Activez les écoles, commerces, gares, parcs et restaurants sur la carte pour voir ce qui est à portée.',
step6Content:
'Activez les écoles, commerces, gares, parcs et restaurants sur la carte pour voir ce qui est à portée.',
},
// ── Server-derived values ──────────────────────────
@ -683,40 +712,35 @@ const fr: Translations = {
// The English keys MUST match exactly what the API returns.
server: {
// ─ Feature group names ─
'Properties': 'Propriétés',
'Transport': 'Transports',
'Education': 'Éducation',
'Deprivation': 'Précarité',
'Crime': 'Criminalité',
'Demographics': 'Démographie',
'Amenities': 'Commodités',
Properties: 'Propriétés',
Transport: 'Transports',
Education: 'Éducation',
Deprivation: 'Précarité',
Crime: 'Criminalité',
Demographics: 'Démographie',
Politics: 'Politique',
Amenities: 'Commodités',
// ─ Feature names (Properties) ─
'Listing status': 'Statut de lannonce',
'Property type': 'Type de bien',
'Leasehold/Freehold': 'Bail/Pleine propriété',
'Last known price': 'Dernier prix connu',
'Estimated current price': 'Prix actuel estimé',
'Asking price': 'Prix demandé',
'Price per sqm': 'Prix au m²',
'Est. price per sqm': 'Prix estimé au m²',
'Asking price per sqm': 'Prix demandé au m²',
'Estimated monthly rent': 'Loyer mensuel estimé',
'Asking rent (monthly)': 'Loyer demandé (mensuel)',
'Total floor area (sqm)': 'Surface totale (m²)',
'Number of bedrooms & living rooms': 'Nombre de chambres et séjours',
'Bedrooms': 'Chambres',
'Bathrooms': 'Salles de bain',
'Construction year': 'Année de construction',
'Date of last transaction': 'Date de la dernière transaction',
'Listing date': 'Date de mise en ligne',
'Former council house': 'Ancien logement social',
'Current energy rating': 'Classement énergétique actuel',
'Potential energy rating': 'Classement énergétique potentiel',
'Interior height (m)': 'Hauteur intérieure (m)',
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)': 'Distance à la gare ou station de métro la plus proche (km)',
'Distance to nearest train or tube station (km)':
'Distance à la gare ou station de métro la plus proche (km)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Écoles primaires Bien+ dans un rayon de 2 km',
@ -762,28 +786,42 @@ const fr: Translations = {
'% Mixed': '% Métis',
'% Other': '% Autres',
// ─ Feature names (Politics) ─
'Winning party': 'Parti vainqueur',
'Voter turnout (%)': 'Participation électorale (%)',
'Majority (%)': 'Majorité (%)',
'% Labour': '% Travaillistes',
'% Conservative': '% Conservateurs',
'% Liberal Democrat': '% Libéraux-démocrates',
'% Reform UK': '% Reform UK',
'% Green': '% Verts',
'% Other parties': '% Autres partis',
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Distance au parc le plus proche (km)',
'Number of parks within 2km': 'Nombre de parcs à moins de 2 km',
'Number of parks within 1km': 'Nombre de parcs à moins de 1 km',
'Number of restaurants within 2km': 'Nombre de restaurants à moins de 2 km',
'Number of grocery shops and supermarkets within 2km': 'Nombre dépiceries et supermarchés à moins de 2 km',
'Number of grocery shops and supermarkets within 2km':
'Nombre dépiceries et supermarchés à moins de 2 km',
'Noise (dB)': 'Bruit (dB)',
'Max available download speed (Mbps)': 'Débit descendant max. disponible (Mbps)',
// ─ Enum values ─
'Historical sale': 'Vente historique',
'For sale': 'En vente',
'For rent': 'En location',
'Detached': 'Individuelle',
Labour: 'Travailliste',
Conservative: 'Conservateur',
'Liberal Democrat': 'Libéral-démocrate',
'Reform UK': 'Reform UK',
Green: 'Vert',
'Other parties': 'Autres partis',
Detached: 'Individuelle',
'Semi-Detached': 'Jumelée',
'Terraced': 'Mitoyenne',
Terraced: 'Mitoyenne',
'Flats/Maisonettes': 'Appartements/Duplex',
'Other': 'Autre',
'Freehold': 'Pleine propriété',
'Leasehold': 'Bail emphytéotique',
'Yes': 'Oui',
'No': 'Non',
Other: 'Autre',
Freehold: 'Pleine propriété',
Leasehold: 'Bail emphytéotique',
Yes: 'Oui',
No: 'Non',
// ─ Stacked chart labels ─
'Serious crime': 'Crimes graves',
@ -792,52 +830,52 @@ const fr: Translations = {
// ─ POI group names ─
'Public Transport': 'Transports en commun',
'Leisure': 'Loisirs',
'Health': 'Santé',
Leisure: 'Loisirs',
Health: 'Santé',
'Emergency Services': 'Services durgence',
'Groceries': 'Alimentation',
Groceries: 'Alimentation',
'Local Businesses': 'Commerces de proximité',
'Culture': 'Culture',
'Services': 'Services',
'Shops': 'Boutiques',
Culture: 'Culture',
Services: 'Services',
Shops: 'Boutiques',
// ─ POI categories ─
'Airport': 'Aéroport',
'Ferry': 'Ferry',
Airport: 'Aéroport',
Ferry: 'Ferry',
'Rail station': 'Gare',
'Bus stop': 'Arrêt de bus',
'Bus station': 'Gare routière',
'Taxi rank': 'Station de taxi',
'Metro or Tram stop': 'Station de métro ou tramway',
'Café': 'Café',
'Restaurant': 'Restaurant',
'Pub': 'Pub',
'Bar': 'Bar',
'Tube station': 'Station de métro',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',
Bar: 'Bar',
'Fast Food': 'Restauration rapide',
'Nightclub': 'Boîte de nuit',
'Cinema': 'Cinéma',
'Theatre': 'Théâtre',
Nightclub: 'Boîte de nuit',
Cinema: 'Cinéma',
Theatre: 'Théâtre',
'Live Music & Events': 'Musique live et événements',
'Park': 'Parc',
'Playground': 'Aire de jeux',
Park: 'Parc',
Playground: 'Aire de jeux',
'Sports Centre': 'Centre sportif',
'Entertainment': 'Divertissement',
'Supermarket': 'Supermarché',
Entertainment: 'Divertissement',
Supermarket: 'Supermarché',
'Convenience Store': 'Supérette',
'Bakery': 'Boulangerie',
Bakery: 'Boulangerie',
'Butcher & Fishmonger': 'Boucherie et poissonnerie',
'Greengrocer': 'Primeur',
Greengrocer: 'Primeur',
'Off-Licence': 'Caviste',
'Deli & Specialty': 'Traiteur et épicerie fine',
'Fashion & Clothing': 'Mode et vêtements',
'Electronics': 'Électronique',
Electronics: 'Électronique',
'Charity Shop': 'Boutique caritative',
'DIY & Hardware': 'Bricolage et quincaillerie',
'Home & Garden': 'Maison et jardin',
'Bookshop': 'Librairie',
Bookshop: 'Librairie',
'Pet Shop': 'Animalerie',
'Sports & Outdoor': 'Sports et plein air',
'Newsagent': 'Marchand de journaux',
Newsagent: 'Marchand de journaux',
'Department Store': 'Grand magasin',
'Gift & Hobby': 'Cadeaux et loisirs créatifs',
'Specialist Shop': 'Boutique spécialisée',
@ -847,31 +885,31 @@ const fr: Translations = {
'Car Services': 'Services automobiles',
'Post Office': 'Bureau de poste',
'Vet & Pet Care': 'Vétérinaire et soins animaliers',
'Bank': 'Banque',
Bank: 'Banque',
'Travel Agent': 'Agence de voyage',
'Police': 'Police',
Police: 'Police',
'Fire Station': 'Caserne de pompiers',
'Ambulance Station': 'Centre ambulancier',
'GP Surgery': 'Cabinet médical',
'Dentist': 'Dentiste',
'Pharmacy': 'Pharmacie',
Dentist: 'Dentiste',
Pharmacy: 'Pharmacie',
'Hospital & Clinic': 'Hôpital et clinique',
'Optician': 'Opticien',
'Physiotherapy': 'Kinésithérapie',
Optician: 'Opticien',
Physiotherapy: 'Kinésithérapie',
'Counselling & Therapy': 'Conseil et thérapie',
'Care Home': 'Maison de retraite',
'Medical & Mobility': 'Matériel médical et mobilité',
'Museum': 'Musée',
'Gallery': 'Galerie',
'Library': 'Bibliothèque',
Museum: 'Musée',
Gallery: 'Galerie',
Library: 'Bibliothèque',
'Place of Worship': 'Lieu de culte',
'Arts Centre': 'Centre artistique',
'Zoo': 'Zoo',
Zoo: 'Zoo',
'Tourist Attraction': 'Attraction touristique',
'School': 'École',
'Hotel': 'Hôtel',
School: 'École',
Hotel: 'Hôtel',
'Local Business': 'Commerce local',
'Offices': 'Bureaux',
Offices: 'Bureaux',
'EV Charging': 'Borne de recharge',
'Fuel Station': 'Station-service',
'Community Centre': 'Centre communautaire',

View file

@ -72,7 +72,8 @@ const hu: Translations = {
logIn: 'Bejelentkezés',
createAccount: 'Regisztráció',
resetPassword: 'Jelszó visszaállítása',
valueProp: 'Mentsd el a kereséseidet, jelöld meg az ingatlanokat, és folytasd ott, ahol abbahagytad.',
valueProp:
'Mentsd el a kereséseidet, jelöld meg az ingatlanokat, és folytasd ott, ahol abbahagytad.',
continueWithGoogle: 'Folytatás Google-lel',
email: 'E-mail',
emailPlaceholder: 'te@pelda.hu',
@ -89,11 +90,13 @@ const hu: Translations = {
// ── Upgrade Modal ──────────────────────────────────
upgrade: {
title: 'Fedezd fel egész Angliát',
description: 'Jelenleg a demó területet felfedezed. Szerezz élethosszig tartó hozzáférést minden irányítószámhoz, szűrőhöz és környékhez. Egyetlen fizetés, örökre.',
description:
'Jelenleg a demó területet felfedezed. Szerezz élethosszig tartó hozzáférést minden irányítószámhoz, szűrőhöz és környékhez. Egyetlen fizetés, örökre.',
free: 'Ingyenes',
once: '/egyszeri',
freeForEarly: 'Ingyenes a korai felhasználóknak. Nem szükséges bankkartya.',
oneTimePayment: 'Egyszeri fizetés. Élethosszig tartó hozzáférés. 30 napos pénzvisszatérítési garancia.',
oneTimePayment:
'Egyszeri fizetés. Élethosszig tartó hozzáférés. 30 napos pénzvisszatérítési garancia.',
redirecting: 'Átirányítás...',
claimFreeAccess: 'Ingyenes hozzáférés igénylése',
upgradeFor: 'Frissítés {{price}} áron',
@ -126,9 +129,6 @@ const hu: Translations = {
filters: {
activeFilters: 'Aktív szűrők',
addFilter: 'Szűrő hozzáadása',
historical: 'Történelmi',
buy: 'Vétel',
rent: 'Bérlés',
findingPerfectPostcode: 'A tökéletes irányítószám megtalálása',
addFiltersHint: 'Adj hozzá szűrőket a térkép szűkítéséhez a feltételeidnek megfelelően',
upgradePrompt: 'Bűnözés, iskolák, zaj, szélessáv és 50+ további szűrő egész Angliában.',
@ -151,7 +151,8 @@ const hu: Translations = {
// ── Philosophy Popup ───────────────────────────────
philosophy: {
intro: '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.',
intro:
'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',
@ -174,7 +175,8 @@ const hu: Translations = {
selectDestination: 'Úticél kiválasztása...',
bestCase: 'Legjobb eset',
bestCaseTitle: 'Legjobb utazási idő',
bestCaseDesc: 'A leggyorsabb reális utazási időt használja (ha jól időzíted az indulást és jó csatlakozásokat érsz el). Az alapértelmezett a <strong>mediánt</strong> használja, ami egy átlagos utazást képvisel, függetlenül az indulás idejétől.',
bestCaseDesc:
'A leggyorsabb reális utazási időt használja (ha jól időzíted az indulást és jó csatlakozásokat érsz el). Az alapértelmezett a <strong>mediánt</strong> használja, ami egy átlagos utazást képvisel, függetlenül az indulás idejétől.',
previewOnMap: 'Előnézet a térképen',
stopPreviewing: 'Előnézet leállítása',
removeTravelTime: 'Utazási idő eltávolítása',
@ -194,7 +196,8 @@ const hu: Translations = {
// ── Travel Time Info Popup ─────────────────────────
travelInfo: {
transitDesc: ' tömegközlekedéssel (busz, vonat, metró). Az időket egy átlagos hétköznap délelőtti időablakra számítjuk.',
transitDesc:
' tömegközlekedéssel (busz, vonat, metró). Az időket egy átlagos hétköznap délelőtti időablakra számítjuk.',
carDesc: ' autóval, a típikus sebességek és az úthálózat alapján.',
bicycleDesc: ' kerékpárral, kerékpárbarát útvonalakon.',
walkingDesc: ' gyalog, sétálóutakon és járdákon.',
@ -208,9 +211,9 @@ const hu: Translations = {
aiSearch: 'AI keresés',
describeHint: 'Írd le, mit keresel',
placeholder: 'pl. csendes terület, £400e alatt, jó iskolák közelében...',
example1: 'Biztonságos terület jó iskolák közelében',
example2: '30 perces ingazás Kings Cross-hoz, £500e alatt',
example3: 'Csendes falu, 3 háló, gyors internet',
example1: 'Ház 40 percre Banktól, alacsony bűnözésű területen',
example2: 'Lakások jó általános iskolák közelében, nem messze Manchestertől',
example3: 'Legjobb ex-council házak 200k alatt',
analysing: 'Lekérdezés elemzése...',
searchingDestinations: 'Úticélok keresése...',
generatingFilters: 'Szűrők létrehozása...',
@ -222,8 +225,6 @@ const hu: Translations = {
mapLegend: {
clearColourView: 'Színezés törlése',
historicalMatches: 'Korábbi ingatlan találatok',
propertiesForSale: 'Eladó ingatlanok',
propertiesForRent: 'Kiadó ingatlanok',
numberOfProperties: 'Ingatlanok száma',
previewing: '\u201c{{name}}\u201d előnézete',
},
@ -233,27 +234,23 @@ const hu: Translations = {
unknownAddress: 'Ismeretlen cím',
unsaveProperty: 'Ingatlan mentésének visszavonása',
saveProperty: 'Ingatlan mentése',
lastSold: 'Utolsó eladás: £{{price}}',
estValue: 'Becsült érték:',
type: 'Típus:',
builtForm: 'Épületforma:',
tenure: 'Tulajdonforma:',
floorArea: 'Alapterület:',
bedrooms: 'Hálószobák:',
bathrooms: 'Fürdőszobák:',
rooms: 'Szobák:',
built: 'Építve:',
formerCouncil: 'Volt önk. lakás:',
exCouncilBadge: 'Volt önk.',
epcRating: 'EPC minősítés:',
epcPotential: 'EPC potenciál:',
listed: 'Hirdetve:',
keyFeatures: 'Főbb jellemzők',
renovations: 'Felújítások',
viewExternalListing: 'Külső hirdetés megtekintése',
perMonth: '/hó',
perSqm: '/m²',
searchPlaceholder: 'Keresés cím vagy irányítószám alapján...',
propertyData: 'Ingatlanadatok',
propertyDataDesc: 'Az árak a HM Land Registry-ből származnak (a vevők által ténylegesen fizetett összeg). Az alapterület, energetikai minősítések, építési év és tulajdonforma a hivatalos EPC felmérésekből származnak. Mindkét forrás cím alapján van összepárosítva az egyes irányítószámokon belül.',
propertyDataDesc:
'Az árak a HM Land Registry-ből származnak (a vevők által ténylegesen fizetett összeg). Az alapterület, energetikai minősítések, építési év és tulajdonforma a hivatalos EPC felmérésekből származnak. Mindkét forrás cím alapján van összepárosítva az egyes irányítószámokon belül.',
},
// ── Area Pane ──────────────────────────────────────
@ -269,6 +266,7 @@ const hu: Translations = {
viewOnGoogleMaps: 'Megtekintés a Google Maps-en',
walk: 'Gyalog',
cycle: 'Kerékpár',
nationalAvg: 'Országos átlag',
},
// ── Histogram Legend ───────────────────────────────
@ -290,7 +288,8 @@ const hu: Translations = {
poiPane: {
pois: 'POI-k',
pointsOfInterest: 'Érdekes pontok',
poiDescription: 'Forrás: OpenStreetMap. Tartalmazza a tömegközlekedési megállókat, üzleteket, éttermeket, egészségügyi intézményeket, szabadidős létesítményeket és még sok mást. Rendszeresen frissítve, teljes kategórialefedettséggel.',
poiDescription:
'Forrás: OpenStreetMap. Tartalmazza a tömegközlekedési megállókat, üzleteket, éttermeket, egészségügyi intézményeket, szabadidős létesítményeket és még sok mást. Rendszeresen frissítve, teljes kategórialefedettséggel.',
searchCategories: 'Kategóriák keresése...',
dataSourceInfo: 'Adatforrás információ',
},
@ -298,6 +297,7 @@ const hu: Translations = {
// ── External Search Links ──────────────────────────
externalSearch: {
searchOn: 'Keresés {{radius}} sugárban ezen:',
exact: 'pontos',
outcodeNotRecognised: 'Nem felismert körzeti kód',
},
@ -323,7 +323,8 @@ const hu: Translations = {
heroTitle2: 'Érték',
heroTitle3: 'Minimális kompromisszum.',
heroSubtitle: 'Ingatlant keresel? Legyen a legnagyobb befektetésed a legokosabb döntésed.',
heroDescription: 'Annyi lehetőség a megfelelő kiválasztása nehéz lehet. Interaktív térképünk egyszerűvé teszi: válaszd ki a feltételeidet, és azonnal lásd a megfelelő területeket.',
heroDescription:
'Annyi lehetőség a megfelelő kiválasztása nehéz lehet. Interaktív térképünk egyszerűvé teszi: válaszd ki a feltételeidet, és azonnal lásd a megfelelő területeket.',
exploreTheMap: 'Térkép felfedezése',
seeTheDifference: 'Nézd meg a különbséget',
statProperties: 'ingatlan',
@ -331,19 +332,22 @@ const hu: Translations = {
statEvery: 'Minden',
statPostcodeInEngland: 'irányítószám Angliában',
ourPhilosophy: 'Filozófiánk',
philosophyP1: 'A Rightmove-on először területet választasz, és reméled, hogy jó. Végül bűnözési statisztikákat, iskolai jelentéseket és szélessáv-ellenőrzőket böngészel tucat füleken, egyszerre egy irányítószámmal.',
philosophyP2: 'Mi megfordítjuk. Mondd el, mire van szükséged (költségvetés, ingazás, iskolák, biztonság), és megmutatjuk Anglia összes megfelelő területét. Nincs találgatás. Nincs felesleges megtekintés.',
philosophyP1:
'A Rightmove-on először területet választasz, és reméled, hogy jó. Végül bűnözési statisztikákat, iskolai jelentéseket és szélessáv-ellenőrzőket böngészel tucat füleken, egyszerre egy irányítószámmal.',
philosophyP2:
'Mi megfordítjuk. Mondd el, mire van szükséged (költségvetés, ingazás, iskolák, biztonság), és megmutatjuk Anglia összes megfelelő területét. Nincs találgatás. Nincs felesleges megtekintés.',
howToUseIt: 'Hogyan használd',
howStep1Title: 'Állítsd be a feltételeidet',
howStep1Desc: 'Költségvetés, ingazás, iskolák — a térkép csak a megfelelőket mutatja.',
howStep2Title: 'Fedezz fel területeket és rejtett kincseket',
howStep2Desc: 'Nagyíts rá, mélyedj el a részletekben és a pluszokban.',
howStep3Title: 'Vizsgáld meg az irányítószámokat',
howStep3Desc: 'Nézd meg az egyes ingatlanokat, eladási árakat, alapterületet, és hasonlítsd össze.',
howStep3Desc:
'Nézd meg az egyes ingatlanokat, eladási árakat, alapterületet, és hasonlítsd össze.',
howStep4Title: 'Válassz magabiztosan',
howStep4Desc: 'A listádon minden terület megfelel a valós feltételeidnek — nem csak annak, amit azon a héten hirdettek.',
howStep4Desc:
'A listádon minden terület megfelel a valós feltételeidnek — nem csak annak, amit azon a héten hirdettek.',
othersVs: 'Mások vs.',
listingPortals: 'Hirdetési portálok',
checkMyPostcode: '“Irányítószám ellenőrzése”',
areaGuides: 'Területi útmutatók',
compSearchWithout: 'Keresés terület előzetes kiválasztása nélkül',
@ -361,8 +365,10 @@ const hu: Translations = {
// ── Pricing Page ───────────────────────────────────
pricingPage: {
title: 'Korai hozzáférés árak',
subtitle: 'Fizess egyszer, használd örökre. Minél korábban csatlakozol, annál kevesebbet fizetsz.',
costContext: 'Egy lakásvásárlás £10 000+ illetékbe, £1 500 ügyvédi díjba, £500 szakértői vizsgálatba kerül. Ha rossz területet választasz, ráragadsz egy hosszú ingazásra, rossz iskolákra, vagy egy útra, amelyről nem tudtál.',
subtitle:
'Fizess egyszer, használd örökre. Minél korábban csatlakozol, annál kevesebbet fizetsz.',
costContext:
'Egy lakásvásárlás £10 000+ illetékbe, £1 500 ügyvédi díjba, £500 szakértői vizsgálatba kerül. Ha rossz területet választasz, ráragadsz egy hosszú ingazásra, rossz iskolákra, vagy egy útra, amelyről nem tudtál.',
lessThanSurvey: 'Kevesebbe kerül, mint egy épületszakértői vizsgálat. Sokkal hasznosabb.',
currentTier: 'Jelenlegi szint',
firstNUsers: 'Első {{count}} felhasználó',
@ -393,13 +399,16 @@ const hu: Translations = {
faq: 'GYIK',
dataSources: 'Adatforrások',
support: 'Támogatás',
dataSourcesIntro: 'Ez az alkalmazás {{count}} nyilvános adatkészletet kombinál, amelyek ingatllanárakat, energetikai teljesítményt, közlekedést, demográfiát, bűnözést, környezetet és még sok mást fednek le.',
faqIntro: 'Akár vásárolsz, akár bérelsz, akár csak felfedezed, így segít a Perfect Postcode megtalálni a megfelelő területet.',
dataSourcesIntro:
'Ez az alkalmazás {{count}} nyilvános adatkészletet kombinál, amelyek ingatllanárakat, energetikai teljesítményt, közlekedést, demográfiát, bűnözést, környezetet és még sok mást fednek le.',
faqIntro:
'Akár vásárolsz, akár bérelsz, akár csak felfedezed, így segít a Perfect Postcode megtalálni a megfelelő területet.',
supportIntro: 'Kérdésed van? Nézd meg a GYIK-et, vagy írj nekünk közvetlenül.',
source: 'Forrás:',
optOut: 'Nyilvános közzététel visszautasítása',
attribution: 'Forrásmegnevezés',
attrLandRegistry: 'HM Land Registry adatokat tartalmaz © Crown copyright and database right 2025.',
attrLandRegistry:
'HM Land Registry adatokat tartalmaz © Crown copyright and database right 2025.',
attrOgl: 'Közszektorbeli információt tartalmaz a következő licenc alatt:',
attrOglLink: 'Open Government Licence v3.0',
attrOs: 'OS adatokat tartalmaz © Crown copyright and database rights 2025.',
@ -414,43 +423,60 @@ const hu: Translations = {
dsPricePaidUse: 'Teljes történelmi ingatlanaladási árak Angliában.',
dsEpcName: 'Energetikai tanúsítványok (EPC)',
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
dsEpcUse: 'Lakóingatlan energetikai tanúsítványok, amelyek tartalmazzpák az alapterületet, szobaszámot, építési évet, energetikai minősítéseket, ingatlantípust és épületformát. Az Árfizetett nyilvántartásokkal cím alapján párosítva az egyes irányítószámokon belül. Az ingatlantulajdonosok visszautasíthatják a nyilvános közzétételt.',
dsEpcUse:
'Lakóingatlan energetikai tanúsítványok, amelyek tartalmazzpák az alapterületet, szobaszámot, építési évet, energetikai minősítéseket, ingatlantípust és épületformát. Az Árfizetett nyilvántartásokkal cím alapján párosítva az egyes irányítószámokon belül. Az ingatlantulajdonosok visszautasíthatják a nyilvános közzétételt.',
dsNsplName: 'Nemzeti Statisztikai Irányítószám Kereső (NSPL)',
dsNsplOrigin: 'ONS / ArcGIS',
dsNsplUse: 'Irányítószámokat koordinátákhoz és statisztikai területkódokhoz rendeli, amelyekkel az összes területi szintű adatkészletet az egyes ingatlanokhoz kapcsoljuk.',
dsNsplUse:
'Irányítószámokat koordinátákhoz és statisztikai területkódokhoz rendeli, amelyekkel az összes területi szintű adatkészletet az egyes ingatlanokhoz kapcsoljuk.',
dsIodName: 'Angol Deprivációs Mutatók 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse: 'Relatív deprivációs pontok 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.',
dsIodUse:
'Relatív deprivációs pontok 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.',
dsEthnicityName: 'Népesség etnikai megoszlás szerint (2021-es népszámlálás)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse: 'Népesség százalékos megoszlása etnikai csoportonként (dél-ázsiai, kelet-ázsiai, fekete, vegyes, fehér, egyéb) helyi önkormányzatonként.',
dsEthnicityUse:
'Népesség százalékos megoszlása etnikai csoportonként (dél-ázsiai, kelet-ázsiai, fekete, vegyes, fehér, egyéb) helyi önkormányzatonként.',
dsCrimeName: 'Utcaszintű bűnözési adatok',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse: 'Utcaszintű bűnözési adatok 2023-tól 2025-ig, éves átlagokba összegézve LSOA-nként és bűncselekménytípusonként (erőszak, betörés, közérdekű rendsértség, kábítószer, járműbűnözés stb.).',
dsCrimeUse:
'Utcaszintű bűnözési adatok 2023-tól 2025-ig, éves átlagokba összegézve LSOA-nként és bűncselekménytípusonként (erőszak, betörés, közérdekű rendsértség, kábítószer, járműbűnözés stb.).',
dsOsmName: 'OpenStreetMap POI-k',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse: 'Érdekes pontok, beleértve üzleteket, éttermeket, egészségügyet, szabadidőt, turizmust és még sok mást Nagy-Britanniában.',
dsOsmUse:
'Érdekes pontok, beleértve üzleteket, éttermeket, egészségügyet, szabadidőt, turizmust és még sok mást Nagy-Britanniában.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse: 'Hivatalos zöldterületi határok Nagy-Britanniában, beleértve a közparkokat, kerteket, sportterületeket és játszótereket. A poligon középpontjait használjuk a park közelségi számláláshoz és a legközelebbi park távolságának számításához.',
dsGreenspaceUse:
'Hivatalos zöldterületi határok Nagy-Britanniában, beleértve a közparkokat, kerteket, sportterületeket és játszótereket. A poligon középpontjait használjuk a park közelségi számláláshoz és a legközelebbi park távolságának számításához.',
dsNaptanName: 'NaPTAN (Tömegközlekedési megállók)',
dsNaptanOrigin: 'Department for Transport',
dsNaptanUse: 'Állomás- és megállóhelyek vasút, busz, metró/villamos, komp és repülőtér számára Angliában.',
dsNaptanUse:
'Állomás- és megállóhelyek vasút, busz, metró/villamos, komp és repülőtér számára Angliában.',
dsNoiseName: 'Defra zajtérképezés',
dsNoiseOrigin: 'Defra / Environment Agency',
dsNoiseUse: 'Közúti zajszintek (24 órás súlyozott átlag) a 2022-es stratégiai zajtérképezésből, nagy felbontásban modellezve és minden irányítószámnál mintavételezve.',
dsNoiseUse:
'Közúti zajszintek (24 órás súlyozott átlag) a 2022-es stratégiai zajtérképezésből, nagy felbontásban modellezve és minden irányítószámnál mintavételezve.',
dsOfstedName: 'Ofsted iskolai vizsgálatok',
dsOfstedOrigin: 'Ofsted',
dsOfstedUse: 'Legfrissebb vizsgálati eredmények az állami fenntartású iskolákról (2025 áprilisáig). Irányítószámonként átlagolva a helyi iskolai minőség pontozásához (1=Kiváló-tól 4=Elégtelenig).',
dsOfstedUse:
'Legfrissebb vizsgálati eredmények az állami fenntartású iskolákról (2025 áprilisáig). Irányítószámonként átlagolva a helyi iskolai minőség pontozásához (1=Kiváló-tól 4=Elégtelenig).',
dsBroadbandName: 'Ofcom szélessávú teljesítmény',
dsBroadbandOrigin: 'Ofcom',
dsBroadbandUse: 'Vezetékes szélessávú lefedettség és maximális letöltési sebességek terültenként az Ofcom Connected Nations 2025 jelentésből.',
dsBroadbandUse:
'Vezetékes szélessávú lefedettség és maximális letöltési sebességek terültenként az Ofcom Connected Nations 2025 jelentésből.',
dsCouncilTaxName: 'Helyi adószintek 2025-26',
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
dsCouncilTaxUse: 'Éves helyi adó díjszabások A-H sávokra Anglia mind a 296 számlázó hatóságánál, két felnőtt által lakott ingatlanra. Az ingatlanokhoz a helyi önkormányzati kerületi kódon keresztül csatolva az NSPL irányítószám keresőből.',
dsCouncilTaxUse:
'Éves helyi adó díjszabások A-H sávokra Anglia mind a 296 számlázó hatóságánál, két felnőtt által lakott ingatlanra. Az ingatlanokhoz a helyi önkormányzati kerületi kódon keresztül csatolva az NSPL irányítószám keresőből.',
dsRentalName: 'Magánbérleti piaci statisztikák',
dsRentalOrigin: 'ONS / Valuation Office Agency',
dsRentalUse: 'Medián havi magánbérleti díjak helyi önkormányzatonként és hálószoba-kategóriánként (2022. okt. 2023. szept.). Az ingatlanokhoz a helyi önkormányzati kerületi kódon és becsült hálószobaszámon keresztül csatolva.',
dsRentalUse:
'Medián havi magánbérleti díjak helyi önkormányzatonként és hálószoba-kategóriánként (2022. okt. 2023. szept.). Az ingatlanokhoz a helyi önkormányzati kerületi kódon és becsült hálószobaszámon keresztül csatolva.',
dsElectionName: '2024-es parlamenti választási eredmények',
dsElectionOrigin: 'Egyesült Királyság Parlamentje',
dsElectionUse:
'Jelöltszintű eredmények a 2024. júliusi brit parlamenti választásról. Választókerületi szintre aggregálva: győztes párt, részvételi arány (%) és többség (%). Az ingatlanokhoz az NSPL irányítószám-keresőből származó parlamenti választókerületi kódon (pcon) keresztül csatolva.',
// FAQ section titles
faqFindingTitle: 'Területed megtalálása',
faqCommuteTitle: 'Ingazás és utazás',
@ -463,61 +489,87 @@ const hu: Translations = {
faqTipsTitle: 'Tippek és trükkök',
// FAQ items — Finding Your Area
faqFinding1Q: 'Fogalmam sincs, hol keressek. Segít ebben?',
faqFinding1A: 'Pont erre való. Állítsd be a szűrőket (költségvetés, ingazási idő, alacsony bűnözés, jó iskolák), és a térkép kivilgítja minden területet, ami megfelel. Nem kell többé éjfélkor guglizni, hogy “hol a legjobb lakni Manchester közelében”.',
faqFinding1A:
'Pont erre való. Állítsd be a szűrőket (költségvetés, ingazási idő, alacsony bűnözés, jó iskolák), és a térkép kivilgítja minden területet, ami megfelel. Nem kell többé éjfélkor guglizni, hogy “hol a legjobb lakni Manchester közelében”.',
faqFinding2Q: 'Olyan helyre költözöm, ahol még soha nem voltam. Hogyan kezdjem?',
faqFinding2A: 'Állítsd be a szűrőket arra, ami fontos, és a térkép azonnal kiemeli a megfelelő területeket. Az “egyetlen utcát sem ismerek”-ből percek alatt rövid listához jutsz.',
faqFinding3Q: 'Hogyan találom meg azokat a területeket, amelyek minden feltételemnek megfelelnek?',
faqFinding3A: 'Kombinálj több szűrőt (bűnözés átlag alatt, jó iskolák, ingazás 40 perc alatt), majd színezd a térképet ár szerint a legjobb értékű területek megtalálásához. A térkép élőben frissül, ahogy a csúszákat húzod.',
faqFinding2A:
'Állítsd be a szűrőket arra, ami fontos, és a térkép azonnal kiemeli a megfelelő területeket. Az “egyetlen utcát sem ismerek”-ből percek alatt rövid listához jutsz.',
faqFinding3Q:
'Hogyan találom meg azokat a területeket, amelyek minden feltételemnek megfelelnek?',
faqFinding3A:
'Kombinálj több szűrőt (bűnözés átlag alatt, jó iskolák, ingazás 40 perc alatt), majd színezd a térképet ár szerint a legjobb értékű területek megtalálásához. A térkép élőben frissül, ahogy a csúszákat húzod.',
// FAQ items — Commute and Travel
faqCommute1Q: 'Láthatom, mennyi lenne az ingazásom különböző területekről?',
faqCommute1A: 'Állítsd be a munkahelyed úticélként, és minden irányítószámot kiszínezünk utazási idő szerint, legyen az autó, kerékpár vagy tömegközlekedés. Szűrj a maximális ingazási időre, és a többi eltűnik.',
faqCommute1A:
'Állítsd be a munkahelyed úticélként, és minden irányítószámot kiszínezünk utazási idő szerint, legyen az autó, kerékpár vagy tömegközlekedés. Szűrj a maximális ingazási időre, és a többi eltűnik.',
faqCommute2Q: 'Miért jobb ez, mint a Google Maps?',
faqCommute2A: 'A Google Maps egyszerre egy utazást mutat. Mi Anglia összes irányítószámát kiszínezzük ingazási idő szerint egyszerre, így száznál több területet hasonlíthatsz össze egyetlen pillantással, ahelyett hogy egyenként keres-gétnéd őket.',
faqCommute2A:
'A Google Maps egyszerre egy utazást mutat. Mi Anglia összes irányítószámát kiszínezzük ingazási idő szerint egyszerre, így száznál több területet hasonlíthatsz össze egyetlen pillantással, ahelyett hogy egyenként keres-gétnéd őket.',
// FAQ items — Budget and Value
faqBudget1Q: 'Hogyan találom meg, hol kapom a legtöbb helyet a pénzememért?',
faqBudget1A: 'Szűrj négyzetméterár szerint, és azonnal látod, mely irányítószámok adják a legtöbb helyet fontonként. Párosítsd az energetikai minősítés szűrővel, hogy elkerüld a magas fűtési költségű ingatlanokat.',
faqBudget1A:
'Szűrj négyzetméterár szerint, és azonnal látod, mely irányítószámok adják a legtöbb helyet fontonként. Párosítsd az energetikai minősítés szűrővel, hogy elkerüld a magas fűtési költségű ingatlanokat.',
faqBudget2Q: 'Hogyan bizonyosodjak meg, hogy egy olcsó terület nem ok nélkül olcsó?',
faqBudget2A: 'Rétegezd rá a deprivációs pontokat, bűnözési statisztikákat, iskolai minősítéseket és szélessáv-sebességeket az ár mellé. Ha egy irányítószám megfizethető és minden fontos szempont szerint jól teljesít, valódi értéket találtál, nem csak alacsony árat észrevétlen kompromisszumokkal.',
faqBudget2A:
'Rétegezd rá a deprivációs pontokat, bűnözési statisztikákat, iskolai minősítéseket és szélessáv-sebességeket az ár mellé. Ha egy irányítószám megfizethető és minden fontos szempont szerint jól teljesít, valódi értéket találtál, nem csak alacsony árat észrevétlen kompromisszumokkal.',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: 'Hogyan ellenőrizhetem, biztonságos-e egy terület, mielőtt odaköltözöm?',
faqSafety1A: 'Valós rendőrségi bűnözési adatokat vetitünk Anglia minden szomszédságára, típusonként lebontva. Szűrj erőszakos bűncselekményre, betörésre vagy közérdekű rendsértségre, és azonnal lásd, mely irányítószámok a legbiztosabbak.',
faqSafety1A:
'Valós rendőrségi bűnözési adatokat vetitünk Anglia minden szomszédságára, típusonként lebontva. Szűrj erőszakos bűncselekményre, betörésre vagy közérdekű rendsértségre, és azonnal lásd, mely irányítószámok a legbiztosabbak.',
faqSafety2Q: 'Folyamatosan találok reméknek tűnő lakásokat online, de a környezet rossz.',
faqSafety2A: 'Pont ezért készült ez. Rétegezd a bűnözési arányokat, zajszinteket, deprivációs pontokat, közeli kocsmkat és parkokat, valamint a szélessáv-sebességeket egyetlen térképre, így tudhatod, milyen valójában egy szomszédság, mielőtt megtekintést foglalsz.',
faqSafety2A:
'Pont ezért készült ez. Rétegezd a bűnözési arányokat, zajszinteket, deprivációs pontokat, közeli kocsmkat és parkokat, valamint a szélessáv-sebességeket egyetlen térképre, így tudhatod, milyen valójában egy szomszédság, mielőtt megtekintést foglalsz.',
// FAQ items — Families and Schools
faqFamilies1Q: 'Találhatok területeket jó iskolákkal ÉS alacsony bűnözéssel egyetlen kereséssel?',
faqFamilies1A: 'Igen. Kombináld az Ofsted minősítések, bűnözési arányok, parkok és bármi más, a családod számára fontos szempont szűrőit, és a térkép csak a minden feltételnek megfelelő területeket emeli ki. Nem kell többé öt különböző weboldalt összevetni.',
faqFamilies1Q:
'Találhatok területeket jó iskolákkal ÉS alacsony bűnözéssel egyetlen kereséssel?',
faqFamilies1A:
'Igen. Kombináld az Ofsted minősítések, bűnözési arányok, parkok és bármi más, a családod számára fontos szempont szűrőit, és a térkép csak a minden feltételnek megfelelő területeket emeli ki. Nem kell többé öt különböző weboldalt összevetni.',
faqFamilies2Q: 'Hogyan tudhatom meg, van-e park és játszótér a közelben?',
faqFamilies2A: 'Kapcsold be a parkok és zöldterületek POI réteget, hogy közvetlenül a térképen lásd őket. Szűrhetsz aszerint is, hány van sétatávolságon belül az egyes irányítószámoktól.',
faqFamilies2A:
'Kapcsold be a parkok és zöldterületek POI réteget, hogy közvetlenül a térképen lásd őket. Szűrhetsz aszerint is, hány van sétatávolságon belül az egyes irányítószámoktól.',
// FAQ items — Environment and Quality of Life
faqEnv1Q: 'Találhatok energiahatékony otthonokat, amelyek nincsenek zajos úton?',
faqEnv1A: 'Szűrj EPC minősítés szerint (A-C), majd rétegezd rá a közúti zajadatokat, hogy kiszűrd a küszöbértéked feletti területeket. Színezd bármelyik jellemző szerint, hogy egy pillantással észrevedd a csendes, hatékony utcákat.',
faqEnv1A:
'Szűrj EPC minősítés szerint (A-C), majd rétegezd rá a közúti zajadatokat, hogy kiszűrd a küszöbértéked feletti területeket. Színezd bármelyik jellemző szerint, hogy egy pillantással észrevedd a csendes, hatékony utcákat.',
faqEnv2Q: 'Mutatja az árvíz- vagy süllyedeskockázatot?',
faqEnv2A: 'Tartalmazunk talajstabilitási adatokat, így ellenőrizheted a süllyeedést, agyagtalan zsugorodás-duzzadást és egyéb geológiai veszélyeket, mielőtt elköteleznéd magad egy ingatlan mellett. Szűrd ki a kockázatos területeket korán.',
faqEnv2A:
'Tartalmazunk talajstabilitási adatokat, így ellenőrizheted a süllyeedést, agyagtalan zsugorodás-duzzadást és egyéb geológiai veszélyeket, mielőtt elköteleznéd magad egy ingatlan mellett. Szűrd ki a kockázatos területeket korán.',
faqEnv3Q: 'Találhatok területeket gyors internettel, amelyek tényleg csendesek?',
faqEnv3A: 'Rétegezd a szélessáv-sebesség szűrőt a közúti zajadatokkal, hogy megtaláld a kitűnő kapcsolattal és alacsony forgalmi zajjal rendelkező utcákat. Színezd bármelyik mérőszám szerint a területek összehasonlításához.',
faqEnv3A:
'Rétegezd a szélessáv-sebesség szűrőt a közúti zajadatokkal, hogy megtaláld a kitűnő kapcsolattal és alacsony forgalmi zajjal rendelkező utcákat. Színezd bármelyik mérőszám szerint a területek összehasonlításához.',
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'Már használom a Rightmove-ot. Mit ad ez hozzá?',
faqWhy1A: 'A Rightmove házakat mutat. Mi területeket. Bűnözési arányok, iskolai minősítések, szélessáv-sebességek, zajszintek, deprivációs pontok és még sok más, minden szűrhető egyetlen térképen. Még azelőtt megítélheted a szomszédságot, hogy akad hirdetésekre néznél.',
faqWhy1A:
'A Rightmove házakat mutat. Mi területeket. Bűnözési arányok, iskolai minősítések, szélessáv-sebességek, zajszintek, deprivációs pontok és még sok más, minden szűrhető egyetlen térképen. Még azelőtt megítélheted a szomszédságot, hogy akad hirdetésekre néznél.',
faqWhy2Q: 'Nem tudom mindezt ingyen is utánanézni?',
faqWhy2A: 'Összevethatnéd a rendőrségi adatokat, Ofsted jelentéseket, EPC nyilvántartást, Land Registry adatokat és ONS statisztikákat egyenként, irányítószámonként. Vagy mindezt szűrhetően és színkódoltan egyetlen térképen, másodpercek alatt.',
faqWhy2A:
'Összevethatnéd a rendőrségi adatokat, Ofsted jelentéseket, EPC nyilvántartást, Land Registry adatokat és ONS statisztikákat egyenként, irányítószámonként. Vagy mindezt szűrhetően és színkódoltan egyetlen térképen, másodpercek alatt.',
faqWhy3Q: 'Honnan származnak az adatok?',
faqWhy3A: 'Minden adatkészlet hivatalos brit kormányzati forrásokból származik: Land Registry, EPC nyilvántartás, ONS, Ofsted, Ofcom, data.police.uk és Defra. Nem scrapelünk ingatlanirrodákat és nem találunk ki semmit. Bármely rekordot ellenőrizheted az eredeti forrásban.',
faqWhy3A:
'Minden adatkészlet hivatalos brit kormányzati forrásokból származik: Land Registry, EPC nyilvántartás, ONS, Ofsted, Ofcom, data.police.uk és Defra. Nem scrapelünk ingatlanirrodákat és nem találunk ki semmit. Bármely rekordot ellenőrizheted az eredeti forrásban.',
// FAQ items — Pricing and Access
faqPricing1Q: 'Tényleg megéri fizetni egy ingatlan-kereső eszközért?',
faqPricing1A: 'Egy lakásvásárlás valószínűleg a legnagyobb vásárlásod lesz. Egyetlen figyelmeztető jel felismerése (zajos út, gyenge internet, növekvő bűnözés) elköteleződés előtt éveknűi megbánást takaríthat meg. Ez kevesebbe kerül, mint egy tank benzin.',
faqPricing1A:
'Egy lakásvásárlás valószínűleg a legnagyobb vásárlásod lesz. Egyetlen figyelmeztető jel felismerése (zajos út, gyenge internet, növekvő bűnözés) elköteleződés előtt éveknűi megbánást takaríthat meg. Ez kevesebbe kerül, mint egy tank benzin.',
faqPricing2Q: 'Ez előfizetés?',
faqPricing2A: 'Nem. Egyszeri fizetés, örökre a tied. Használd intenzíven a keresés során, gyere vissza bármikor, ha kíváncsi vagy egy új területre, és még mindig ott van, ha újra költözöl.',
faqPricing2A:
'Nem. Egyszeri fizetés, örökre a tied. Használd intenzíven a keresés során, gyere vissza bármikor, ha kíváncsi vagy egy új területre, és még mindig ott van, ha újra költözöl.',
faqPricing3Q: 'Mit érhetek el az ingyenes szinten?',
faqPricing3A: 'Az ingyenes felhasználók a demó területen (Belső-London, megközelítőleg az 1-2. zóna) fedezhetik fel az összes funkciót. Anglia többi részének adataihoz élethosszig tartó hozzáférés szükséges.',
faqPricing3A:
'Az ingyenes felhasználók a demó területen (Belső-London, megközelítőleg az 1-2. zóna) fedezhetik fel az összes funkciót. Anglia többi részének adataihoz élethosszig tartó hozzáférés szükséges.',
faqPricing4Q: 'Kérhetek visszatérítést?',
faqPricing4A: 'Természetesen. 30 napos pénzvisszatérítési garanciát kínálunk. Ha nem vagy elégedett, írj a support@perfect-postcode.co.uk címre 30 napon belül a teljes visszatérítésért.',
faqPricing4A:
'Természetesen. 30 napos pénzvisszatérítési garanciát kínálunk. Ha nem vagy elégedett, írj a support@perfect-postcode.co.uk címre 30 napon belül a teljes visszatérítésért.',
// FAQ items — Tips and Tricks
faqTips1Q: 'Hogyan használjam az AI szűrőt a szűrők egyenkénti hozzáadása helyett?',
faqTips1A: 'Írd le egyszerű angolul, mit szeretnél, például “csendes terület jó iskolák közelében, gyors internettel, £400e alatt”, és az összes megfelelő szűrőt egyszerre beállítja. Utána bármelyiket kézzel finomhangolhatod.',
faqTips1A:
'Írd le egyszerű angolul, mit szeretnél, például “csendes terület jó iskolák közelében, gyors internettel, £400e alatt”, és az összes megfelelő szűrőt egyszerre beállítja. Utána bármelyiket kézzel finomhangolhatod.',
faqTips2Q: 'Elmenthetem a keresést, és később visszatérhetek hozzá?',
faqTips2A: 'Nyomd meg a mentés gombot, és mindent rögzítünk: szűrőid, a nagyítási szint, és melyik adatréteg szerint színezel. Folytasd pontosan ott, ahol abbahagytad, vagy oszd meg a linket a pároddal.',
faqTips2A:
'Nyomd meg a mentés gombot, és mindent rögzítünk: szűrőid, a nagyítási szint, és melyik adatréteg szerint színezel. Folytasd pontosan ott, ahol abbahagytad, vagy oszd meg a linket a pároddal.',
faqTips3Q: 'Exportálhatom az adatokat, amiket látok?',
faqTips3A: 'Az exportálás gombbal letöltheted a jelenlegi szűrőknek megfelelő ingatlanokat táblázatként. Az export figyelembe veszi az összes aktív szűrőt, így pontosan azokat az adatokat kapod, amiket szeretnél.',
faqTips3A:
'Az exportálás gombbal letöltheted a jelenlegi szűrőknek megfelelő ingatlanokat táblázatként. Az export figyelembe veszi az összes aktív szűrőt, így pontosan azokat az adatokat kapod, amiket szeretnél.',
},
// ── Account Page ───────────────────────────────────
@ -535,17 +587,20 @@ const hu: Translations = {
savedPage: {
searches: 'Keresések',
noSavedSearches: 'Még nincsenek mentett keresések',
noSavedSearchesDesc: 'Mentsd el a szűrőket és a térképnézetet, hogy pontosan ott folytasd, ahol abbahagytad.',
noSavedSearchesDesc:
'Mentsd el a szűrőket és a térképnézetet, hogy pontosan ott folytasd, ahol abbahagytad.',
noSavedProperties: 'Még nincsenek mentett ingatlanok',
noSavedPropertiesDesc: 'Jelöld meg az ingatlanokat felfedezés közben, és építsd a rövid listádat elvesztés nélkül.',
noSavedPropertiesDesc:
'Jelöld meg az ingatlanokat felfedezés közben, és építsd a rövid listádat elvesztés nélkül.',
openPostcode: 'Irányítószám megnyitása',
viewListing: 'Hirdetés megtekintése',
clickToRename: 'Kattints az átnevezéshez',
notesPlaceholder: 'Írd le a gondolataidat...',
deleteSearch: 'Keresés törlése',
deleteSearchConfirm: 'Biztosan törölni szeretnéd ezt a mentett keresést? Ez nem vonható vissza.',
deleteSearchConfirm:
'Biztosan törölni szeretnéd ezt a mentett keresést? Ez nem vonható vissza.',
deleteProperty: 'Ingatlan törlése',
deletePropertyConfirm: 'Biztosan törölni szeretnéd ezt a mentett ingatlant? Ez nem vonható vissza.',
deletePropertyConfirm:
'Biztosan törölni szeretnéd ezt a mentett ingatlant? Ez nem vonható vissza.',
bed: 'háló',
epc: 'EPC',
},
@ -574,11 +629,14 @@ const hu: Translations = {
youreInvited: 'Meghívást kaptál!',
specialOffer: 'Különleges ajánlat!',
invitedByFree: '{{name}} meghívott, hogy ingyenes élethosszig tartó hozzáférést kapj.',
invitedByDiscount: '{{name}} megoszt veled egy 30%-os kedvezményt az élethosszig tartó hozzáférésre.',
invitedByDiscount:
'{{name}} megoszt veled egy 30%-os kedvezményt az élethosszig tartó hozzáférésre.',
genericFreeInvite: 'Meghívást kaptál ingyenes élethosszig tartó hozzáférésre.',
genericDiscount: 'Egy barát megoszt veled egy 30%-os kedvezményt az élethosszig tartó hozzáférésre.',
genericDiscount:
'Egy barát megoszt veled egy 30%-os kedvezményt az élethosszig tartó hozzáférésre.',
exploreEvery: 'Fedezd fel Anglia minden szomszédságát',
propertyInfo: 'Ingatlanárak, energetikai minősítések, bűnözési adatok, iskolai minősítések és még sok más',
propertyInfo:
'Ingatlanárak, energetikai minősítések, bűnözési adatok, iskolai minősítések és még sok más',
invalidInvite: 'Érvénytelen meghívó',
inviteAlreadyUsed: 'A meghívó már felhasználva',
inviteAlreadyUsedDesc: 'Ez a meghívó link már be lett váltva.',
@ -624,17 +682,22 @@ const hu: Translations = {
// ── Tutorial ──────────────────────────────────────
tutorial: {
step1Title: 'Mondja el a térképnek, mi fontos',
step1Content: 'Állítsa be a költségvetést, maximalis ingazási időt, iskola minőséget és bűnözési kúszöböt. Ami Önnek fontos. Csak a megfelelő területek maradnak kiemelve. Használja a szem ikont bármely jellemző szerinti színezéshez.',
step1Content:
'Állítsa be a költségvetést, maximalis ingazási időt, iskola minőséget és bűnözési kúszöböt. Ami Önnek fontos. Csak a megfelelő területek maradnak kiemelve. Használja a szem ikont bármely jellemző szerinti színezéshez.',
step2Title: 'Vagy egyszerűen írja le',
step2Content: 'Írja le magyarul, mit keres, például „csendes terület jó iskolák közelében £400k alatt”, és beállítjuk a szűrőket Önnek.',
step2Content:
'Írja le magyarul, mit keres, például „csendes terület jó iskolák közelében £400k alatt”, és beállítjuk a szűrőket Önnek.',
step3Title: 'Fedezze fel, mi van odakint',
step3Content: 'Görgessen és nagyítson Anglia-szerte. Kattintson bármely színes területre a bűnözés, iskolák, árak, szélessáv, zaj és egyéb adatok megtekintéséhez.',
step3Content:
'Görgessen és nagyítson Anglia-szerte. Kattintson bármely színes területre a bűnözés, iskolák, árak, szélessáv, zaj és egyéb adatok megtekintéséhez.',
step4Title: 'Ugrás egy helyre',
step4Content: 'Keressen rá bármely helyre vagy irányítószámra, hogy azonnal odajusson.',
step5Title: 'Merüljön el a részletekben',
step5Content: 'Tekintse meg a területi statisztikákat, hisztogramokat és az egyes ingatlanadatokat: árak, alapterület, energetikai besorolás és több.',
step5Content:
'Tekintse meg a területi statisztikákat, hisztogramokat és az egyes ingatlanadatokat: árak, alapterület, energetikai besorolás és több.',
step6Title: 'Mi van a közelben?',
step6Content: 'Kapcsolja be az iskolákat, üzleteket, állomásokat, parkokat és éttermeket a térképen, hogy lássa, mi érhető el.',
step6Content:
'Kapcsolja be az iskolákat, üzleteket, állomásokat, parkokat és éttermeket a térképen, hogy lássa, mi érhető el.',
},
// ── Server-derived values ──────────────────────────
@ -642,40 +705,35 @@ const hu: Translations = {
// The English keys MUST match exactly what the API returns.
server: {
// ─ Feature group names ─
'Properties': 'Ingatlanok',
'Transport': 'Közlekedés',
'Education': 'Oktatás',
'Deprivation': 'Depriváció',
'Crime': 'Bűnözés',
'Demographics': 'Demográfia',
'Amenities': 'Szolgáltatások',
Properties: 'Ingatlanok',
Transport: 'Közlekedés',
Education: 'Oktatás',
Deprivation: 'Depriváció',
Crime: 'Bűnözés',
Demographics: 'Demográfia',
Politics: 'Politika',
Amenities: 'Szolgáltatások',
// ─ Feature names (Properties) ─
'Listing status': 'Hirdetés állapota',
'Property type': 'Ingatlantípus',
'Leasehold/Freehold': 'Bérleti/Tulajdonjog',
'Last known price': 'Utolsó ismert ár',
'Estimated current price': 'Becsült jelenlegi ár',
'Asking price': 'Hirdetési ár',
'Price per sqm': 'Ár per nm',
'Est. price per sqm': 'Becsült ár per nm',
'Asking price per sqm': 'Hirdetési ár per nm',
'Estimated monthly rent': 'Becsült havi bérleti díj',
'Asking rent (monthly)': 'Kért bérleti díj (havi)',
'Total floor area (sqm)': 'Teljes alapterület (nm)',
'Number of bedrooms & living rooms': 'Háló- és nappalik száma',
'Bedrooms': 'Hálószobák',
'Bathrooms': 'Fürdőszobák',
'Construction year': 'Építési év',
'Date of last transaction': 'Utolsó tranzakció dátuma',
'Listing date': 'Hirdetés dátuma',
'Former council house': 'Volt önkormányzati lakás',
'Current energy rating': 'Jelenlegi energetikai minősítés',
'Potential energy rating': 'Potenciális energetikai minősítés',
'Interior height (m)': 'Belmagasság (m)',
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)': 'Távolság a legközelebbi vonat- vagy metróállomástól (km)',
'Distance to nearest train or tube station (km)':
'Távolság a legközelebbi vonat- vagy metróállomástól (km)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Jó+ általános iskolák 2 km-en belül',
@ -721,27 +779,42 @@ const hu: Translations = {
'% Mixed': '% vegyes',
'% Other': '% egyéb',
// ─ Feature names (Politics) ─
'Winning party': 'Győztes párt',
'Voter turnout (%)': 'Választási részvétel (%)',
'Majority (%)': 'Többség (%)',
'% Labour': '% Munkáspárt',
'% Conservative': '% Konzervatív',
'% Liberal Democrat': '% Liberális Demokrata',
'% Reform UK': '% Reform UK',
'% Green': '% Zöld',
'% Other parties': '% Egyéb pártok',
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Távolság a legközelebbi parktól (km)',
'Number of parks within 2km': 'Parkok száma 2 km-en belül',
'Number of parks within 1km': 'Parkok száma 1 km-en belül',
'Number of restaurants within 2km': 'Éttermek száma 2 km-en belül',
'Number of grocery shops and supermarkets within 2km': 'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
'Number of grocery shops and supermarkets within 2km':
'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
'Noise (dB)': 'Zaj (dB)',
'Max available download speed (Mbps)': 'Max elérhető letöltési sebesség (Mbps)',
// ─ Enum values ─
'Historical sale': 'Történelmi eladás',
'For sale': 'Eladó',
'For rent': 'Kiadó',
'Detached': 'Különálló',
Labour: 'Munkáspárt',
Conservative: 'Konzervatív',
'Liberal Democrat': 'Liberális Demokrata',
'Reform UK': 'Reform UK',
Green: 'Zöld',
'Other parties': 'Egyéb pártok',
Detached: 'Különálló',
'Semi-Detached': 'Ikerház',
'Terraced': 'Sorház',
Terraced: 'Sorház',
'Flats/Maisonettes': 'Lakások/Maisonette-ek',
'Other': 'Egyéb',
'Freehold': 'Tulajdonjog',
'Leasehold': 'Bérleti jog',
'Yes': 'Igen',
'No': 'Nem',
Other: 'Egyéb',
Freehold: 'Tulajdonjog',
Leasehold: 'Bérleti jog',
Yes: 'Igen',
No: 'Nem',
// ─ Stacked chart labels ─
'Serious crime': 'Súlyos bűncselekmény',
@ -750,52 +823,52 @@ const hu: Translations = {
// ─ POI group names ─
'Public Transport': 'Tömegközlekedés',
'Leisure': 'Szabadidő',
'Health': 'Egészségügy',
Leisure: 'Szabadidő',
Health: 'Egészségügy',
'Emergency Services': 'Sürgősségi szolgálatok',
'Groceries': 'Élelmiszer',
Groceries: 'Élelmiszer',
'Local Businesses': 'Helyi vállalkozások',
'Culture': 'Kultúra',
'Services': 'Szolgáltatások',
'Shops': 'Üzletek',
Culture: 'Kultúra',
Services: 'Szolgáltatások',
Shops: 'Üzletek',
// ─ POI categories ─
'Airport': 'Repülőtér',
'Ferry': 'Komp',
Airport: 'Repülőtér',
Ferry: 'Komp',
'Rail station': 'Vasútállomás',
'Bus stop': 'Buszmegálló',
'Bus station': 'Buszpályaudvar',
'Taxi rank': 'Taxiállomás',
'Metro or Tram stop': 'Metró- vagy villamosmegálló',
'Café': 'Kávézó',
'Restaurant': 'Étterem',
'Pub': 'Kocsma',
'Bar': 'Bár',
'Tube station': 'Metróállomás',
Café: 'Kávézó',
Restaurant: 'Étterem',
Pub: 'Kocsma',
Bar: 'Bár',
'Fast Food': 'Gyorsétterem',
'Nightclub': 'Éjszakai klub',
'Cinema': 'Mozi',
'Theatre': 'Színház',
Nightclub: 'Éjszakai klub',
Cinema: 'Mozi',
Theatre: 'Színház',
'Live Music & Events': 'Élőzene és rendezvények',
'Park': 'Park',
'Playground': 'Játszótér',
Park: 'Park',
Playground: 'Játszótér',
'Sports Centre': 'Sportközpont',
'Entertainment': 'Szórakoztatás',
'Supermarket': 'Szupermarket',
Entertainment: 'Szórakoztatás',
Supermarket: 'Szupermarket',
'Convenience Store': 'Kísbolt',
'Bakery': 'Pékség',
Bakery: 'Pékség',
'Butcher & Fishmonger': 'Hentes és halas',
'Greengrocer': 'Zöldséges',
Greengrocer: 'Zöldséges',
'Off-Licence': 'Italozó',
'Deli & Specialty': 'Csemege és különleges',
'Fashion & Clothing': 'Divat és ruházat',
'Electronics': 'Elektronika',
Electronics: 'Elektronika',
'Charity Shop': 'Jótékonysági bolt',
'DIY & Hardware': 'Barkacs és vas',
'Home & Garden': 'Otthon és kert',
'Bookshop': 'Könyvesbolt',
Bookshop: 'Könyvesbolt',
'Pet Shop': 'Állatkereskedés',
'Sports & Outdoor': 'Sport és szabadtér',
'Newsagent': 'Újságárus',
Newsagent: 'Újságárus',
'Department Store': 'Áruház',
'Gift & Hobby': 'Ajándék és hobbi',
'Specialist Shop': 'Szaküzlet',
@ -805,31 +878,31 @@ const hu: Translations = {
'Car Services': 'Autós szolgáltatások',
'Post Office': 'Posta',
'Vet & Pet Care': 'Állatorvos és állatgondozás',
'Bank': 'Bank',
Bank: 'Bank',
'Travel Agent': 'Utazási iroda',
'Police': 'Rendőrség',
Police: 'Rendőrség',
'Fire Station': 'Tűzoltóság',
'Ambulance Station': 'Mentőállomás',
'GP Surgery': 'Háziorvosi rendelő',
'Dentist': 'Fogorvos',
'Pharmacy': 'Gyógyszertár',
Dentist: 'Fogorvos',
Pharmacy: 'Gyógyszertár',
'Hospital & Clinic': 'Kórház és klinika',
'Optician': 'Optikus',
'Physiotherapy': 'Fizioterápia',
Optician: 'Optikus',
Physiotherapy: 'Fizioterápia',
'Counselling & Therapy': 'Tanácsadás és terápia',
'Care Home': 'Gondozóház',
'Medical & Mobility': 'Egészségügyi és mobilitási eszközök',
'Museum': 'Múzeum',
'Gallery': 'Galéria',
'Library': 'Könyvtár',
Museum: 'Múzeum',
Gallery: 'Galéria',
Library: 'Könyvtár',
'Place of Worship': 'Istentiszteleti hely',
'Arts Centre': 'Művészeti központ',
'Zoo': 'Állatkert',
Zoo: 'Állatkert',
'Tourist Attraction': 'Turisztikai látványosság',
'School': 'Iskola',
'Hotel': 'Szálloda',
School: 'Iskola',
Hotel: 'Szálloda',
'Local Business': 'Helyi vállalkozás',
'Offices': 'Irodák',
Offices: 'Irodák',
'EV Charging': 'Elektromos töltőállomás',
'Fuel Station': 'Benzinkút',
'Community Centre': 'Közösségi központ',

View file

@ -88,7 +88,8 @@ const zh: Translations = {
// ── Upgrade Modal ──────────────────────────────────
upgrade: {
title: '查看整个英格兰',
description: '您目前正在浏览演示区域。获取终身访问权限,覆盖每个邮编、每项筛选条件、每个社区。一次付款,永久使用。',
description:
'您目前正在浏览演示区域。获取终身访问权限,覆盖每个邮编、每项筛选条件、每个社区。一次付款,永久使用。',
free: '免费',
once: '/一次性',
freeForEarly: '早期用户免费。无需信用卡。',
@ -125,9 +126,6 @@ const zh: Translations = {
filters: {
activeFilters: '当前筛选条件',
addFilter: '添加筛选条件',
historical: '历史交易',
buy: '买房',
rent: '租房',
findingPerfectPostcode: '寻找理想的邮编',
addFiltersHint: '添加以下筛选条件,将地图缩小到符合您要求的区域',
upgradePrompt: '查看犯罪率、学校、噪音、宽带等 50 多项筛选条件,覆盖整个英格兰。',
@ -150,7 +148,8 @@ const zh: Translations = {
// ── Philosophy Popup ───────────────────────────────
philosophy: {
intro: '从必须满足的条件开始,再逐步添加加分项。每添加一个筛选条件,地图范围就会缩小。剩下的区域就是最适合您的。',
intro:
'从必须满足的条件开始,再逐步添加加分项。每添加一个筛选条件,地图范围就会缩小。剩下的区域就是最适合您的。',
step1Title: '预算和基本条件',
step1Desc: '(价格范围、建筑面积、房产类型)',
step2Title: '通勤',
@ -173,7 +172,8 @@ const zh: Translations = {
selectDestination: '选择目的地...',
bestCase: '最佳情况',
bestCaseTitle: '最佳通勤时间',
bestCaseDesc: '使用最快的实际出行时间(如果您把握好出发时间并赶上良好的换乘)。默认使用<strong>中位数</strong>,代表无论何时出发的典型出行时间。',
bestCaseDesc:
'使用最快的实际出行时间(如果您把握好出发时间并赶上良好的换乘)。默认使用<strong>中位数</strong>,代表无论何时出发的典型出行时间。',
previewOnMap: '在地图上预览',
stopPreviewing: '停止预览',
removeTravelTime: '移除通勤时间',
@ -207,9 +207,9 @@ const zh: Translations = {
aiSearch: 'AI 搜索',
describeHint: '描述您要找的区域',
placeholder: '例如:安静的区域,低于 £40万靠近好学校...',
example1: '安全的区域,靠近好学校',
example2: '到国王十字站30分钟通勤低于 £50万',
example3: '安静的村庄3间卧室快速宽带',
example1: '距Bank站40分钟的低犯罪率地区的房子',
example2: '曼彻斯特附近好小学周围的公寓',
example3: '20万以下最好的前政府住房',
analysing: '正在分析您的需求...',
searchingDestinations: '正在搜索目的地...',
generatingFilters: '正在生成筛选条件...',
@ -221,8 +221,6 @@ const zh: Translations = {
mapLegend: {
clearColourView: '清除颜色视图',
historicalMatches: '历史房产匹配',
propertiesForSale: '待售房产',
propertiesForRent: '待租房产',
numberOfProperties: '房产数量',
previewing: '预览\u201c{{name}}\u201d',
},
@ -232,27 +230,23 @@ const zh: Translations = {
unknownAddress: '地址未知',
unsaveProperty: '取消收藏',
saveProperty: '收藏房产',
lastSold: '上次成交价:£{{price}}',
estValue: '估计价值:',
type: '类型:',
builtForm: '建筑形式:',
tenure: '产权:',
floorArea: '建筑面积:',
bedrooms: '卧室:',
bathrooms: '浴室:',
rooms: '房间:',
built: '建造年份:',
formerCouncil: '原公房:',
exCouncilBadge: '原公房',
epcRating: '能源评级:',
epcPotential: '潜在能源评级:',
listed: '上市日期:',
keyFeatures: '主要特点',
renovations: '翻新记录',
viewExternalListing: '查看外部房源',
perMonth: '/月',
perSqm: '/m²',
searchPlaceholder: '按地址或邮编搜索...',
propertyData: '房产数据',
propertyDataDesc: '价格来自英国土地注册局(买家实际支付的金额)。建筑面积、能源评级、建造年份和产权来自官方能源性能证书调查。两个数据源通过每个邮编内的地址进行匹配。',
propertyDataDesc:
'价格来自英国土地注册局(买家实际支付的金额)。建筑面积、能源评级、建造年份和产权来自官方能源性能证书调查。两个数据源通过每个邮编内的地址进行匹配。',
},
// ── Area Pane ──────────────────────────────────────
@ -268,6 +262,7 @@ const zh: Translations = {
viewOnGoogleMaps: '在 Google Maps 上查看',
walk: '步行',
cycle: '骑行',
nationalAvg: '全国平均',
},
// ── Histogram Legend ───────────────────────────────
@ -289,7 +284,8 @@ const zh: Translations = {
poiPane: {
pois: '兴趣点',
pointsOfInterest: '兴趣点',
poiDescription: '数据来自 OpenStreetMap。涵盖公共交通站点、商店、餐厅、医疗机构、休闲场所等。定期更新类别覆盖完整。',
poiDescription:
'数据来自 OpenStreetMap。涵盖公共交通站点、商店、餐厅、医疗机构、休闲场所等。定期更新类别覆盖完整。',
searchCategories: '搜索类别...',
dataSourceInfo: '数据来源信息',
},
@ -297,6 +293,7 @@ const zh: Translations = {
// ── External Search Links ──────────────────────────
externalSearch: {
searchOn: '在 {{radius}} 范围内搜索',
exact: '精确',
outcodeNotRecognised: '无法识别该邮编区域',
},
@ -322,7 +319,8 @@ const zh: Translations = {
heroTitle2: '价值',
heroTitle3: '最小妥协。',
heroSubtitle: '正在找房?让您最大的投资成为最明智的决定。',
heroDescription: '选择太多,找到合适的可能让人不知所措。我们的交互式地图让一切变得简单:选择您的必要条件,立即看到符合的区域。',
heroDescription:
'选择太多,找到合适的可能让人不知所措。我们的交互式地图让一切变得简单:选择您的必要条件,立即看到符合的区域。',
exploreTheMap: '探索地图',
seeTheDifference: '看看有何不同',
statProperties: '处房产',
@ -330,8 +328,10 @@ const zh: Translations = {
statEvery: '覆盖',
statPostcodeInEngland: '英格兰每个邮编',
ourPhilosophy: '我们的理念',
philosophyP1: '在 Rightmove 上,您需要先选一个区域,然后期望它足够好。最终您不得不在十几个标签页中交叉对比犯罪数据、学校报告和宽带速度,一个邮编一个邮编地查。',
philosophyP2: '我们反其道而行。告诉我们您的需求(预算、通勤、学校、安全),我们为您展示英格兰所有符合条件的区域。不用猜测,不浪费看房时间。',
philosophyP1:
'在 Rightmove 上,您需要先选一个区域,然后期望它足够好。最终您不得不在十几个标签页中交叉对比犯罪数据、学校报告和宽带速度,一个邮编一个邮编地查。',
philosophyP2:
'我们反其道而行。告诉我们您的需求(预算、通勤、学校、安全),我们为您展示英格兰所有符合条件的区域。不用猜测,不浪费看房时间。',
howToUseIt: '使用方法',
howStep1Title: '设定必要条件',
howStep1Desc: '预算、通勤、学校——地图只显示符合条件的区域。',
@ -342,7 +342,6 @@ const zh: Translations = {
howStep4Title: '自信地列出候选名单',
howStep4Desc: '您名单上的每个区域都满足您的实际需求——而不只是当周恰好有房源。',
othersVs: '其他平台 vs',
listingPortals: '房源网站',
checkMyPostcode: '"查查我的邮编"类网站',
areaGuides: '区域指南',
compSearchWithout: '无需先选区域即可搜索',
@ -361,7 +360,8 @@ const zh: Translations = {
pricingPage: {
title: '早期访问价格',
subtitle: '一次付款,永久访问。越早加入,价格越优惠。',
costContext: '买房需要支付超过 £10,000 的印花税、£1,500 的律师费、£500 的房屋评估费。选错区域,您可能要忍受漫长的通勤、差劲的学校,或一条您事先不知道的嘈杂马路。',
costContext:
'买房需要支付超过 £10,000 的印花税、£1,500 的律师费、£500 的房屋评估费。选错区域,您可能要忍受漫长的通勤、差劲的学校,或一条您事先不知道的嘈杂马路。',
lessThanSurvey: '不到一次房屋评估的费用,却有用得多。',
currentTier: '当前档位',
firstNUsers: '前 {{count}} 名用户',
@ -392,7 +392,8 @@ const zh: Translations = {
faq: '常见问题',
dataSources: '数据来源',
support: '支持',
dataSourcesIntro: '本应用整合了 {{count}} 个开放数据集,涵盖房产价格、能源性能、交通、人口统计、犯罪、环境等领域。',
dataSourcesIntro:
'本应用整合了 {{count}} 个开放数据集,涵盖房产价格、能源性能、交通、人口统计、犯罪、环境等领域。',
faqIntro: '无论您是购房、租房还是单纯浏览,以下是 Perfect Postcode 如何帮助您找到理想区域。',
supportIntro: '有问题?请查看我们的常见问题或直接联系我们。',
source: '来源:',
@ -413,7 +414,8 @@ const zh: Translations = {
dsPricePaidUse: '英格兰完整的历史房产成交价格数据。',
dsEpcName: 'Energy Performance Certificates (EPC)',
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
dsEpcUse: '住宅能源性能证书,提供建筑面积、房间数量、建造年份、能源评级、房产类型和建筑形式等信息。通过每个邮编内的地址与成交价格数据进行匹配。业主可以退出公开披露。',
dsEpcUse:
'住宅能源性能证书,提供建筑面积、房间数量、建造年份、能源评级、房产类型和建筑形式等信息。通过每个邮编内的地址与成交价格数据进行匹配。业主可以退出公开披露。',
dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
dsNsplOrigin: 'ONS / ArcGIS',
dsNsplUse: '将邮编映射到坐标和统计区域代码,用于将所有区域级数据集关联到各个房产。',
@ -422,34 +424,45 @@ const zh: Translations = {
dsIodUse: '英格兰每个社区在收入、就业、教育、健康、犯罪和居住环境方面的相对贫困指数。',
dsEthnicityName: '按族裔划分的人口2021 年人口普查)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse: '按族裔群体(南亚裔、东亚裔、黑人、混血、白人、其他)划分的各地方政府辖区人口百分比。',
dsEthnicityUse:
'按族裔群体(南亚裔、东亚裔、黑人、混血、白人、其他)划分的各地方政府辖区人口百分比。',
dsCrimeName: 'Street-level Crime Data',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse: '2023 年至 2025 年的街道级犯罪数据,按 LSOA 和犯罪类型(暴力犯罪、入室盗窃、反社会行为、毒品、车辆犯罪等)汇总为年均值。',
dsCrimeUse:
'2023 年至 2025 年的街道级犯罪数据,按 LSOA 和犯罪类型(暴力犯罪、入室盗窃、反社会行为、毒品、车辆犯罪等)汇总为年均值。',
dsOsmName: 'OpenStreetMap POIs',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse: '涵盖大不列颠地区的商店、餐厅、医疗、休闲、旅游等兴趣点。',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse: '大不列颠地区权威的绿地边界数据,包括公共公园、花园、运动场和游乐场。多边形质心用于公园邻近度计数和最近公园距离计算。',
dsGreenspaceUse:
'大不列颠地区权威的绿地边界数据,包括公共公园、花园、运动场和游乐场。多边形质心用于公园邻近度计数和最近公园距离计算。',
dsNaptanName: 'NaPTAN (Public Transport Stops)',
dsNaptanOrigin: 'Department for Transport',
dsNaptanUse: '英格兰各地铁路、公交、地铁/有轨电车、渡轮和机场的站点位置。',
dsNoiseName: 'Defra Noise Mapping',
dsNoiseOrigin: 'Defra / Environment Agency',
dsNoiseUse: '来自 2022 年战略噪音测绘的道路噪音水平24 小时加权平均值),经高分辨率建模并在每个邮编处采样。',
dsNoiseUse:
'来自 2022 年战略噪音测绘的道路噪音水平24 小时加权平均值),经高分辨率建模并在每个邮编处采样。',
dsOfstedName: 'Ofsted School Inspections',
dsOfstedOrigin: 'Ofsted',
dsOfstedUse: '公立学校最新督察结果(截至 2025 年 4 月。按邮编取平均值得出当地学校质量评分1=优秀至4=不合格)。',
dsOfstedUse:
'公立学校最新督察结果(截至 2025 年 4 月。按邮编取平均值得出当地学校质量评分1=优秀至4=不合格)。',
dsBroadbandName: 'Ofcom Broadband Performance',
dsBroadbandOrigin: 'Ofcom',
dsBroadbandUse: '来自 Ofcom Connected Nations 2025 的各区域固定宽带覆盖率和最大下载速度。',
dsCouncilTaxName: 'Council Tax Levels 2025-26',
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
dsCouncilTaxUse: '英格兰所有 296 个计费机构的 A 至 H 等级年度市政税税率,适用于两名成年人居住的住宅。通过 NSPL 邮编查询中的地方政府区域代码关联到房产。',
dsCouncilTaxUse:
'英格兰所有 296 个计费机构的 A 至 H 等级年度市政税税率,适用于两名成年人居住的住宅。通过 NSPL 邮编查询中的地方政府区域代码关联到房产。',
dsRentalName: 'Private Rental Market Statistics',
dsRentalOrigin: 'ONS / Valuation Office Agency',
dsRentalUse: '按地方政府辖区和卧室类别划分的月度私人租金中位数2022 年 10 月至 2023 年 9 月)。通过地方政府区域代码和估算卧室数量关联到房产。',
dsRentalUse:
'按地方政府辖区和卧室类别划分的月度私人租金中位数2022 年 10 月至 2023 年 9 月)。通过地方政府区域代码和估算卧室数量关联到房产。',
dsElectionName: '2024年大选结果',
dsElectionOrigin: '英国议会',
dsElectionUse:
'2024年7月英国大选的候选人级别结果。聚合到选区级别获胜政党、投票率%)和多数票(%。通过NSPL邮编查询中的议会选区代码pcon关联到房产。',
// FAQ section titles
faqFindingTitle: '寻找理想区域',
faqCommuteTitle: '通勤与出行',
@ -462,61 +475,85 @@ const zh: Translations = {
faqTipsTitle: '使用技巧',
// FAQ items — Finding Your Area
faqFinding1Q: '我完全不知道该看哪些区域,这个工具能帮到我吗?',
faqFinding1A: '这正是它的用途。设置您的筛选条件(预算、通勤时间、低犯罪率、好学校),地图就会亮起来,显示所有符合条件的区域。不用再半夜搜索"曼彻斯特附近最好的居住区"了。',
faqFinding1A:
'这正是它的用途。设置您的筛选条件(预算、通勤时间、低犯罪率、好学校),地图就会亮起来,显示所有符合条件的区域。不用再半夜搜索"曼彻斯特附近最好的居住区"了。',
faqFinding2Q: '我要搬到一个从未去过的地方,该从何开始?',
faqFinding2A: '设置您关心的筛选条件,地图会立即高亮显示符合条件的区域。从"我一条街都不认识"到得出候选名单,只需几分钟。',
faqFinding2A:
'设置您关心的筛选条件,地图会立即高亮显示符合条件的区域。从"我一条街都不认识"到得出候选名单,只需几分钟。',
faqFinding3Q: '如何找到同时满足我所有要求的区域?',
faqFinding3A: '叠加多个筛选条件(犯罪率低于平均水平、好学校、通勤时间少于 40 分钟),然后按价格为地图着色,找出性价比最高的区域。拖动滑块时地图会实时更新,让您即时看到变化。',
faqFinding3A:
'叠加多个筛选条件(犯罪率低于平均水平、好学校、通勤时间少于 40 分钟),然后按价格为地图着色,找出性价比最高的区域。拖动滑块时地图会实时更新,让您即时看到变化。',
// FAQ items — Commute and Travel
faqCommute1Q: '我能看到从不同区域到公司的实际通勤时间吗?',
faqCommute1A: '设置您的工作地点作为目的地,我们会按通勤时间为每个邮编着色——无论是开车、骑车还是公共交通。筛选出您的最大通勤时间,其余区域就会消失。',
faqCommute1A:
'设置您的工作地点作为目的地,我们会按通勤时间为每个邮编着色——无论是开车、骑车还是公共交通。筛选出您的最大通勤时间,其余区域就会消失。',
faqCommute2Q: '这比查 Google Maps 好在哪里?',
faqCommute2A: 'Google Maps 一次只能查看一条路线。我们一次性将英格兰每个邮编按通勤时间着色,让您可以同时比较数百个区域,而不是逐个搜索。',
faqCommute2A:
'Google Maps 一次只能查看一条路线。我们一次性将英格兰每个邮编按通勤时间着色,让您可以同时比较数百个区域,而不是逐个搜索。',
// FAQ items — Budget and Value
faqBudget1Q: '如何找到单位面积性价比最高的区域?',
faqBudget1A: '按每平方米价格筛选,您会立即看到哪些邮编的单位面积价格最低。搭配能源评级筛选,避免取暖费用过高的房产。',
faqBudget1A:
'按每平方米价格筛选,您会立即看到哪些邮编的单位面积价格最低。搭配能源评级筛选,避免取暖费用过高的房产。',
faqBudget2Q: '怎么确定一个便宜的区域不是因为有问题才便宜?',
faqBudget2A: '将贫困指数、犯罪统计、学校评级和宽带速度叠加在价格旁边查看。如果一个邮编价格实惠且在各项重要指标上表现良好,那您就找到了真正的高性价比——而不是隐藏着您还没发现的问题的低价。',
faqBudget2A:
'将贫困指数、犯罪统计、学校评级和宽带速度叠加在价格旁边查看。如果一个邮编价格实惠且在各项重要指标上表现良好,那您就找到了真正的高性价比——而不是隐藏着您还没发现的问题的低价。',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: '搬家前如何查看一个区域是否安全?',
faqSafety1A: '我们将真实的警方犯罪记录数据按类型细分,叠加到英格兰每个社区上。按暴力犯罪、入室盗窃或反社会行为筛选,立即看到哪些邮编的犯罪数据最低。',
faqSafety1A:
'我们将真实的警方犯罪记录数据按类型细分,叠加到英格兰每个社区上。按暴力犯罪、入室盗窃或反社会行为筛选,立即看到哪些邮编的犯罪数据最低。',
faqSafety2Q: '我总是找到网上看起来很好的房子,到了才发现周边环境很差。',
faqSafety2A: '这正是这个工具存在的意义。在一张地图上叠加犯罪率、噪音水平、贫困指数、附近的酒吧和公园以及宽带速度,这样您在预约看房之前就能了解一个社区的真实面貌。',
faqSafety2A:
'这正是这个工具存在的意义。在一张地图上叠加犯罪率、噪音水平、贫困指数、附近的酒吧和公园以及宽带速度,这样您在预约看房之前就能了解一个社区的真实面貌。',
// FAQ items — Families and Schools
faqFamilies1Q: '我能在一次搜索中找到学校好又犯罪率低的区域吗?',
faqFamilies1A: '可以。叠加 Ofsted 评级、犯罪率、公园等对您家庭重要的筛选条件,地图只会高亮显示符合所有条件的区域。不用再在五个不同网站之间交叉比对了。',
faqFamilies1A:
'可以。叠加 Ofsted 评级、犯罪率、公园等对您家庭重要的筛选条件,地图只会高亮显示符合所有条件的区域。不用再在五个不同网站之间交叉比对了。',
faqFamilies2Q: '如何知道一个社区附近是否有公园和游乐场?',
faqFamilies2A: '打开公园和绿地 POI 图层,直接在地图上查看。您还可以按每个邮编步行范围内的公园数量进行筛选。',
faqFamilies2A:
'打开公园和绿地 POI 图层,直接在地图上查看。您还可以按每个邮编步行范围内的公园数量进行筛选。',
// FAQ items — Environment and Quality of Life
faqEnv1Q: '能找到不在嘈杂马路旁的节能住宅吗?',
faqEnv1A: '按 EPC 评级A 至 C筛选然后叠加道路噪音数据排除超过您阈值的区域。按任一指标为地图着色一目了然地找到安静且节能的街道。',
faqEnv1A:
'按 EPC 评级A 至 C筛选然后叠加道路噪音数据排除超过您阈值的区域。按任一指标为地图着色一目了然地找到安静且节能的街道。',
faqEnv2Q: '有洪水或地基沉降风险数据吗?',
faqEnv2A: '我们包含地基稳定性数据,让您在购房前检查沉降、膨胀收缩黏土和其他地质风险。尽早排除高风险区域。',
faqEnv2A:
'我们包含地基稳定性数据,让您在购房前检查沉降、膨胀收缩黏土和其他地质风险。尽早排除高风险区域。',
faqEnv3Q: '能找到宽带速度快又安静的区域吗?',
faqEnv3A: '将宽带速度筛选与道路噪音数据叠加,找到连接速度快且交通噪音低的街道。按任一指标着色,一目了然地比较各区域。',
faqEnv3A:
'将宽带速度筛选与道路噪音数据叠加,找到连接速度快且交通噪音低的街道。按任一指标着色,一目了然地比较各区域。',
// FAQ items — Why Perfect Postcode
faqWhy1Q: '我已经在用 Rightmove 了,这个工具有什么额外价值?',
faqWhy1A: 'Rightmove 展示房源,我们展示区域。犯罪率、学校评级、宽带速度、噪音水平、贫困指数等等——全部可在一张地图上筛选。您可以在查看房源之前先了解一个社区。',
faqWhy1A:
'Rightmove 展示房源,我们展示区域。犯罪率、学校评级、宽带速度、噪音水平、贫困指数等等——全部可在一张地图上筛选。您可以在查看房源之前先了解一个社区。',
faqWhy2Q: '我不能自己免费查到这些信息吗?',
faqWhy2A: '您当然可以逐个邮编地交叉比对警方数据、Ofsted 报告、EPC 登记、Land Registry 记录和 ONS 统计数据。或者,您可以在几秒钟内在一张地图上筛选和查看所有信息。',
faqWhy2A:
'您当然可以逐个邮编地交叉比对警方数据、Ofsted 报告、EPC 登记、Land Registry 记录和 ONS 统计数据。或者,您可以在几秒钟内在一张地图上筛选和查看所有信息。',
faqWhy3Q: '数据到底来自哪里?',
faqWhy3A: '每个数据集都来自英国官方政府来源Land Registry、EPC 登记、ONS、Ofsted、Ofcom、data.police.uk 和 Defra。我们不抓取房产中介数据也不编造任何信息。您可以对照原始来源验证任何记录。',
faqWhy3A:
'每个数据集都来自英国官方政府来源Land Registry、EPC 登记、ONS、Ofsted、Ofcom、data.police.uk 和 Defra。我们不抓取房产中介数据也不编造任何信息。您可以对照原始来源验证任何记录。',
// FAQ items — Pricing and Access
faqPricing1Q: '花钱买一个找房工具真的值得吗?',
faqPricing1A: '买房可能是您一生中最大的一笔支出。在做决定之前发现一个问题(嘈杂的马路、差劲的宽带、上升的犯罪率)就可能让您避免多年的后悔。而这个工具的费用还不到一箱油钱。',
faqPricing1A:
'买房可能是您一生中最大的一笔支出。在做决定之前发现一个问题(嘈杂的马路、差劲的宽带、上升的犯罪率)就可能让您避免多年的后悔。而这个工具的费用还不到一箱油钱。',
faqPricing2Q: '这是订阅制吗?',
faqPricing2A: '不是。一次性付款,永久使用。在找房期间密集使用,对新区域好奇时随时回来看,将来再搬家时它依然在。',
faqPricing2A:
'不是。一次性付款,永久使用。在找房期间密集使用,对新区域好奇时随时回来看,将来再搬家时它依然在。',
faqPricing3Q: '免费版能用哪些功能?',
faqPricing3A: '免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内探索所有功能。要访问英格兰其他地区的数据,需要获取终身访问权限。',
faqPricing3A:
'免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内探索所有功能。要访问英格兰其他地区的数据,需要获取终身访问权限。',
faqPricing4Q: '可以退款吗?',
faqPricing4A: '当然可以。我们提供 30 天退款保证。如果您不满意,请在 30 天内发送邮件至 support@perfect-postcode.co.uk 申请全额退款。',
faqPricing4A:
'当然可以。我们提供 30 天退款保证。如果您不满意,请在 30 天内发送邮件至 support@perfect-postcode.co.uk 申请全额退款。',
// FAQ items — Tips and Tricks
faqTips1Q: '如何使用 AI 筛选功能,而不是逐个添加筛选条件?',
faqTips1A: '用自然语言描述您的需求,例如"安静的区域、好学校附近、宽带速度快、40 万英镑以下",系统会一次性设置所有相关筛选条件。之后您可以手动微调。',
faqTips1A:
'用自然语言描述您的需求,例如"安静的区域、好学校附近、宽带速度快、40 万英镑以下",系统会一次性设置所有相关筛选条件。之后您可以手动微调。',
faqTips2Q: '我能保存搜索条件以后再用吗?',
faqTips2A: '点击保存按钮,所有内容都会被记录:您的筛选条件、缩放级别以及当前着色的数据图层。下次从上次离开的地方继续,或将链接分享给您的伴侣。',
faqTips2A:
'点击保存按钮,所有内容都会被记录:您的筛选条件、缩放级别以及当前着色的数据图层。下次从上次离开的地方继续,或将链接分享给您的伴侣。',
faqTips3Q: '我能导出正在查看的数据吗?',
faqTips3A: '使用导出按钮将当前筛选后的房产下载为电子表格。导出结果会遵循您所有的活动筛选条件,确保您获得的正是所需的数据。',
faqTips3A:
'使用导出按钮将当前筛选后的房产下载为电子表格。导出结果会遵循您所有的活动筛选条件,确保您获得的正是所需的数据。',
},
// ── Account Page ───────────────────────────────────
@ -538,7 +575,6 @@ const zh: Translations = {
noSavedProperties: '暂无保存的房产',
noSavedPropertiesDesc: '在浏览过程中收藏房产,建立您的候选名单,不会遗漏任何一处。',
openPostcode: '打开邮编',
viewListing: '查看房源',
clickToRename: '点击重命名',
notesPlaceholder: '记下您的想法...',
deleteSearch: '删除搜索',
@ -623,11 +659,14 @@ const zh: Translations = {
// ── Tutorial ──────────────────────────────────────
tutorial: {
step1Title: '告诉地图什么重要',
step1Content: '设置预算、通勤上限、学校质量、犯罪门槛。您关心的一切。只有符合条件的区域会保持高亮。使用眼睛图标按任意特征着色。',
step1Content:
'设置预算、通勤上限、学校质量、犯罪门槛。您关心的一切。只有符合条件的区域会保持高亮。使用眼睛图标按任意特征着色。',
step2Title: '或者直接描述',
step2Content: '用中文输入您的需求例如“安静的地区靠近好学校£400k 以下”,我们会为您设置筛选。',
step2Content:
'用中文输入您的需求例如“安静的地区靠近好学校£400k 以下”,我们会为您设置筛选。',
step3Title: '探索现有住宅',
step3Content: '在英格兰各地平移和缩放。点击任何彩色区域查看犯罪、学校、价格、宽带、噪音等信息。',
step3Content:
'在英格兰各地平移和缩放。点击任何彩色区域查看犯罪、学校、价格、宽带、噪音等信息。',
step4Title: '跳转到某个位置',
step4Content: '搜索任何地点或邮编,即可直接跳转。',
step5Title: '深入了解细节',
@ -641,33 +680,27 @@ const zh: Translations = {
// The English keys MUST match exactly what the API returns.
server: {
// ─ Feature group names ─
'Properties': '房产',
'Transport': '交通',
'Education': '教育',
'Deprivation': '贫困指数',
'Crime': '犯罪',
'Demographics': '人口统计',
'Amenities': '配套设施',
Properties: '房产',
Transport: '交通',
Education: '教育',
Deprivation: '贫困指数',
Crime: '犯罪',
Demographics: '人口统计',
Politics: '政治',
Amenities: '配套设施',
// ─ Feature names (Properties) ─
'Listing status': '房源状态',
'Property type': '房产类型',
'Leasehold/Freehold': '租赁产权/永久产权',
'Last known price': '上次成交价',
'Estimated current price': '估计当前价格',
'Asking price': '挂牌价',
'Price per sqm': '每平方米价格',
'Est. price per sqm': '估计每平方米价格',
'Asking price per sqm': '挂牌价每平方米',
'Estimated monthly rent': '估计月租',
'Asking rent (monthly)': '月租',
'Total floor area (sqm)': '总建筑面积(平方米)',
'Number of bedrooms & living rooms': '卧室和客厅数量',
'Bedrooms': '卧室',
'Bathrooms': '浴室',
'Construction year': '建造年份',
'Date of last transaction': '上次交易日期',
'Listing date': '上市日期',
'Former council house': '原公共住房',
'Current energy rating': '当前能源评级',
'Potential energy rating': '潜在能源评级',
@ -720,28 +753,41 @@ const zh: Translations = {
'% Mixed': '% 混血',
'% Other': '% 其他',
// ─ Feature names (Politics) ─
'Winning party': '获胜政党',
'Voter turnout (%)': '投票率(%',
'Majority (%)': '多数票(%',
'% Labour': '% 工党',
'% Conservative': '% 保守党',
'% Liberal Democrat': '% 自由民主党',
'% Reform UK': '% 英国改革党',
'% Green': '% 绿党',
'% Other parties': '% 其他政党',
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': '到最近公园的距离(公里)',
'Number of parks within 2km': '2公里内公园数量',
'Number of parks within 1km': '1公里内公园数量',
'Number of restaurants within 2km': '2公里内餐厅数量',
'Number of grocery shops and supermarkets within 2km': '2公里内食品店和超市数量',
'Noise (dB)': '噪音(分贝)',
'Max available download speed (Mbps)': '最大可用下载速度Mbps',
// ─ Enum values ─
'Historical sale': '历史交易',
'For sale': '在售',
'For rent': '出租',
'Detached': '独立式住宅',
Labour: '工党',
Conservative: '保守党',
'Liberal Democrat': '自由民主党',
'Reform UK': '英国改革党',
Green: '绿党',
'Other parties': '其他政党',
Detached: '独立式住宅',
'Semi-Detached': '半独立式住宅',
'Terraced': '联排住宅',
Terraced: '联排住宅',
'Flats/Maisonettes': '公寓/复式公寓',
'Other': '其他',
'Freehold': '永久产权',
'Leasehold': '租赁产权',
'Yes': '是',
'No': '否',
Other: '其他',
Freehold: '永久产权',
Leasehold: '租赁产权',
Yes: '是',
No: '否',
// ─ Stacked chart labels ─
'Serious crime': '严重犯罪',
@ -750,52 +796,52 @@ const zh: Translations = {
// ─ POI group names ─
'Public Transport': '公共交通',
'Leisure': '休闲',
'Health': '健康',
Leisure: '休闲',
Health: '健康',
'Emergency Services': '紧急服务',
'Groceries': '食品杂货',
Groceries: '食品杂货',
'Local Businesses': '本地商业',
'Culture': '文化',
'Services': '服务',
'Shops': '商店',
Culture: '文化',
Services: '服务',
Shops: '商店',
// ─ POI categories ─
'Airport': '机场',
'Ferry': '渡轮',
Airport: '机场',
Ferry: '渡轮',
'Rail station': '火车站',
'Bus stop': '公交站',
'Bus station': '公交枢纽',
'Taxi rank': '出租车站',
'Metro or Tram stop': '地铁或有轨电车站',
'Café': '咖啡馆',
'Restaurant': '餐厅',
'Pub': '酒吧',
'Bar': '酒吧',
'Tube station': '地铁站',
Café: '咖啡馆',
Restaurant: '餐厅',
Pub: '酒吧',
Bar: '酒吧',
'Fast Food': '快餐',
'Nightclub': '夜店',
'Cinema': '电影院',
'Theatre': '剧院',
Nightclub: '夜店',
Cinema: '电影院',
Theatre: '剧院',
'Live Music & Events': '现场音乐与活动',
'Park': '公园',
'Playground': '游乐场',
Park: '公园',
Playground: '游乐场',
'Sports Centre': '体育中心',
'Entertainment': '娱乐',
'Supermarket': '超市',
Entertainment: '娱乐',
Supermarket: '超市',
'Convenience Store': '便利店',
'Bakery': '面包戺',
Bakery: '面包戺',
'Butcher & Fishmonger': '肉铺与鱼铺',
'Greengrocer': '果蔬店',
Greengrocer: '果蔬店',
'Off-Licence': '酒类商店',
'Deli & Specialty': '熟食与特产店',
'Fashion & Clothing': '时装服饰',
'Electronics': '电子产品',
Electronics: '电子产品',
'Charity Shop': '慈善商店',
'DIY & Hardware': '建材五金',
'Home & Garden': '家居与园艺',
'Bookshop': '书店',
Bookshop: '书店',
'Pet Shop': '宠物店',
'Sports & Outdoor': '体育与户外',
'Newsagent': '报刊亭',
Newsagent: '报刊亭',
'Department Store': '百货商店',
'Gift & Hobby': '礼品与爱好',
'Specialist Shop': '专业商店',
@ -805,31 +851,31 @@ const zh: Translations = {
'Car Services': '汽车服务',
'Post Office': '邮局',
'Vet & Pet Care': '宠物医院与护理',
'Bank': '银行',
Bank: '银行',
'Travel Agent': '旅行社',
'Police': '警察',
Police: '警察',
'Fire Station': '消防站',
'Ambulance Station': '急救站',
'GP Surgery': '全科诊所',
'Dentist': '牙科',
'Pharmacy': '药房',
Dentist: '牙科',
Pharmacy: '药房',
'Hospital & Clinic': '医院与诊所',
'Optician': '眼镜店',
'Physiotherapy': '理疗',
Optician: '眼镜店',
Physiotherapy: '理疗',
'Counselling & Therapy': '心理咨询与治疗',
'Care Home': '养老院',
'Medical & Mobility': '医疗器械与辅助设备',
'Museum': '博物馆',
'Gallery': '美术馆',
'Library': '图书馆',
Museum: '博物馆',
Gallery: '美术馆',
Library: '图书馆',
'Place of Worship': '宗教场所',
'Arts Centre': '艺术中心',
'Zoo': '动物园',
Zoo: '动物园',
'Tourist Attraction': '旅游景点',
'School': '学校',
'Hotel': '酒店',
School: '学校',
Hotel: '酒店',
'Local Business': '本地商业',
'Offices': '写字楼',
Offices: '写字楼',
'EV Charging': '电动车充电站',
'Fuel Station': '加油站',
'Community Centre': '社区中心',

View file

@ -11,5 +11,5 @@ export function ts(value: string): string {
return typeof result === 'string' ? result : value;
}
// Re-export tsDesc from descriptions.ts for convenience
export { tsDesc } from './descriptions';
// Re-export tsDesc and tsDetail from descriptions.ts for convenience
export { tsDesc, tsDetail } from './descriptions';

View file

@ -0,0 +1,137 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { LayerExtension } from '@deck.gl/core';
import { ENUM_PALETTE } from './consts';
/**
* LayerExtension that turns polygon fills into pie charts.
*
* Follows the canonical deck.gl v9 extension pattern:
* - defaultProps with type:'accessor' enables getSubLayerProps() to wrap
* accessors via getSubLayerAccessor(), which unwraps __source.object to
* access the original data item through CompositeLayer sublayer chains.
* - stepMode:'dynamic' handles per-instance counting automatically.
* - isEnabled() restricts to SolidPolygonLayer (fill) sublayers only.
*
* Accepts an optional custom palette in the constructor for per-feature color overrides.
*/
function paletteToGlsl(palette: [number, number, number][]): string {
return palette
.map(
(c) =>
`vec3(${(c[0] / 255).toFixed(4)}, ${(c[1] / 255).toFixed(4)}, ${(c[2] / 255).toFixed(4)})`
)
.join(',\n ');
}
export class PieHexExtension extends LayerExtension {
static extensionName = 'PieHexExtension';
static defaultProps = {
getCenter: { type: 'accessor', value: [0, 0] },
getRatios0: { type: 'accessor', value: [1, 0, 0, 0] },
getRatios1: { type: 'accessor', value: [0, 0, 0, 0] },
getRatios2: { type: 'accessor', value: [0, 0] },
};
private paletteGlsl: string;
constructor(palette?: [number, number, number][]) {
super();
this.paletteGlsl = paletteToGlsl(palette ?? ENUM_PALETTE);
}
isEnabled(layer: any): boolean {
return layer.id.endsWith('-fill');
}
getShaders(extension: any): any {
if (!extension.isEnabled(this)) return null;
return {
modules: [
{
name: 'pieHex',
inject: {
'vs:#decl': `\
in vec2 instancePieCenter;
in vec4 instanceRatios0;
in vec4 instanceRatios1;
in vec2 instanceRatios2;
out vec2 vPieCenter;
out vec2 vPieFragPos;
out vec4 vRatios0;
out vec4 vRatios1;
out vec2 vRatios2;`,
'vs:#main-end': `\
vPieCenter = project_position(vec3(instancePieCenter, 0.0)).xy;
vPieFragPos = geometry.position.xy;
vRatios0 = instanceRatios0;
vRatios1 = instanceRatios1;
vRatios2 = instanceRatios2;`,
'fs:#decl': `\
in vec2 vPieCenter;
in vec2 vPieFragPos;
in vec4 vRatios0;
in vec4 vRatios1;
in vec2 vRatios2;
const vec3 pieColors[10] = vec3[10](
${this.paletteGlsl}
);`,
'fs:DECKGL_FILTER_COLOR': `\
{
vec2 delta = vPieFragPos - vPieCenter;
float angle = atan(delta.x, -delta.y) / (2.0 * 3.14159265) + 0.5;
float ratios[10];
ratios[0] = vRatios0.x; ratios[1] = vRatios0.y;
ratios[2] = vRatios0.z; ratios[3] = vRatios0.w;
ratios[4] = vRatios1.x; ratios[5] = vRatios1.y;
ratios[6] = vRatios1.z; ratios[7] = vRatios1.w;
ratios[8] = vRatios2.x; ratios[9] = vRatios2.y;
float cumulative = 0.0;
vec3 sliceColor = pieColors[0];
for (int i = 0; i < 10; i++) {
cumulative += ratios[i];
if (angle < cumulative) {
sliceColor = pieColors[i];
break;
}
}
color = vec4(sliceColor, 1.0);
}`,
},
uniformTypes: {},
},
],
};
}
initializeState(this: any, _context: any, extension: any): void {
if (!extension.isEnabled(this)) return;
const am = this.getAttributeManager();
if (!am) return;
am.add({
instancePieCenter: {
size: 2,
stepMode: 'dynamic',
accessor: 'getCenter',
},
instanceRatios0: {
size: 4,
stepMode: 'dynamic',
accessor: 'getRatios0',
},
instanceRatios1: {
size: 4,
stepMode: 'dynamic',
accessor: 'getRatios1',
},
instanceRatios2: {
size: 2,
stepMode: 'dynamic',
accessor: 'getRatios2',
},
});
}
}

View file

@ -109,6 +109,8 @@ export const STACKED_GROUPS: Record<
label: string;
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
feature?: string;
/** If set, display this feature's mean as the primary value (e.g. per-1k rate) instead of the absolute total. */
rateFeature?: string;
/** Suffix shown after the total value (e.g. "avg/yr") */
unit?: string;
/** Feature names that make up the segments */
@ -119,7 +121,8 @@ export const STACKED_GROUPS: Record<
{
label: 'Serious crime',
feature: 'Serious crime (avg/yr)',
unit: 'avg/yr',
rateFeature: 'Serious crime per 1k residents (avg/yr)',
unit: 'per 1k/yr',
components: [
'Violence and sexual offences (avg/yr)',
'Robbery (avg/yr)',
@ -130,7 +133,8 @@ export const STACKED_GROUPS: Record<
{
label: 'Minor crime',
feature: 'Minor crime (avg/yr)',
unit: 'avg/yr',
rateFeature: 'Minor crime per 1k residents (avg/yr)',
unit: 'per 1k/yr',
components: [
'Anti-social behaviour (avg/yr)',
'Criminal damage and arson (avg/yr)',
@ -179,7 +183,7 @@ export const STACKED_ENUM_GROUPS: Record<
feature: 'Property type',
components: ['Property type'],
valueOrder: ['Detached', 'Semi-Detached', 'Terraced', 'Flats/Maisonettes', 'Other'],
valueColors: ['#8b5cf6', '#3b82f6', '#14b8a6', '#f59e0b', '#6b7280'],
valueColors: ['#f97316', '#3b82f6', '#22c55e', '#ec4899', '#6b7280'],
},
{
label: 'Leasehold/Freehold',
@ -208,6 +212,60 @@ export const ENUM_PALETTE: [number, number, number][] = [
[107, 114, 128], // gray-500
];
/**
* Per-feature color overrides for enum values on the map and dashboard.
* Keys are feature names (as returned by the server), values map enum value RGB.
* Any value not listed falls back to ENUM_PALETTE by index.
*/
export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number, number]>> = {
'Winning party': {
Labour: [220, 36, 31], // Labour red
Conservative: [0, 135, 220], // Conservative blue
'Liberal Democrat': [253, 187, 48], // Lib Dem gold
'Reform UK': [18, 178, 196], // Reform teal
Green: [106, 176, 35], // Green party green
'Other parties': [148, 130, 160], // muted purple
},
'Property type': {
Detached: [249, 115, 22], // orange
'Semi-Detached': [59, 130, 246], // blue
Terraced: [34, 197, 94], // green
'Flats/Maisonettes': [236, 72, 153], // pink
Other: [107, 114, 128], // gray
},
};
/**
* Build a 10-color palette for a given feature, using overrides where defined.
* Returns the default ENUM_PALETTE when no overrides exist.
*/
export function getEnumPaletteForFeature(
featureName: string | null,
values?: string[]
): [number, number, number][] {
if (!featureName || !values) return ENUM_PALETTE;
const overrides = ENUM_COLOR_OVERRIDES[featureName];
if (!overrides) return ENUM_PALETTE;
const palette: [number, number, number][] = [];
for (let i = 0; i < 10; i++) {
if (i < values.length && overrides[values[i]]) {
palette.push(overrides[values[i]]);
} else {
palette.push(ENUM_PALETTE[i % ENUM_PALETTE.length]);
}
}
return palette;
}
/** Look up override color for a specific enum value, or null if none. */
export function getEnumValueColor(
featureName: string,
valueName: string
): [number, number, number] | null {
return ENUM_COLOR_OVERRIDES[featureName]?.[valueName] ?? null;
}
/** Colors for stacked bar segments */
export const SEGMENT_COLORS = [
'#ef4444', // red-500

View file

@ -49,12 +49,6 @@ const RIGHTMOVE_PRICES = [
3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000,
];
// Rightmove allowed monthly rent values (pcm)
const RIGHTMOVE_RENTS = [
250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500,
4000, 5000, 7500, 10000, 15000, 25000,
];
// OnTheMarket allowed buy prices
const OTM_PRICES = [
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000, 160000,
@ -64,12 +58,6 @@ const OTM_PRICES = [
10000000, 15000000,
];
// OnTheMarket allowed monthly rent values (pcm)
const OTM_RENTS = [
100, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000, 1100,
1200, 1250, 1300, 1400, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 5000, 7500, 10000, 25000,
];
// Zoopla allowed buy prices
const ZOOPLA_PRICES = [
10000, 25000, 50000, 75000, 100000, 125000, 150000, 175000, 200000, 225000, 250000, 275000,
@ -78,12 +66,6 @@ const ZOOPLA_PRICES = [
5000000, 7500000, 10000000, 15000000,
];
// Zoopla allowed monthly rent values (pcm)
const ZOOPLA_RENTS = [
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500, 4000,
5000, 7500, 10000, 25000,
];
function snapToAllowed(value: number, allowed: number[], direction: 'floor' | 'ceil'): number {
if (direction === 'floor') {
for (let i = allowed.length - 1; i >= 0; i--) {
@ -115,26 +97,14 @@ export function buildPropertySearchUrls({
rightmove: string | null;
onthemarket: string;
zoopla: string;
openrent: string | null;
} | null {
const { postcode, resolution, isPostcode } = location;
if (!postcode) return null;
const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
const listingStatus = filters['Listing status'];
const isRent =
Array.isArray(listingStatus) &&
typeof listingStatus[0] === 'string' &&
(listingStatus as string[]).includes('For rent');
// Check price filters in priority order: asking price (current listings) > estimated > last known
// For rent mode, check asking rent first
const priceFilter = isRent
? filters['Asking rent (monthly)']
: (filters['Asking price'] ??
filters['Estimated current price'] ??
filters['Last known price']);
const priceFilter =
filters['Estimated current price'] ?? filters['Last known price'];
const minPrice =
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
const maxPrice =
@ -146,26 +116,6 @@ export function buildPropertySearchUrls({
? (propertyTypes as string[])
: [];
const bedroomFilter = filters['Bedrooms'];
const minBedrooms =
Array.isArray(bedroomFilter) && typeof bedroomFilter[0] === 'number'
? bedroomFilter[0]
: undefined;
const maxBedrooms =
Array.isArray(bedroomFilter) && typeof bedroomFilter[1] === 'number'
? bedroomFilter[1]
: undefined;
const bathroomFilter = filters['Bathrooms'];
const minBathrooms =
Array.isArray(bathroomFilter) && typeof bathroomFilter[0] === 'number'
? bathroomFilter[0]
: undefined;
const maxBathrooms =
Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number'
? bathroomFilter[1]
: undefined;
const tenureFilter = filters['Leasehold/Freehold'];
const selectedTenures =
Array.isArray(tenureFilter) && typeof tenureFilter[0] === 'string'
@ -175,20 +125,15 @@ export function buildPropertySearchUrls({
// Rightmove — requires locationIdentifier from typeahead API
let rightmove: string | null = null;
if (rightmoveLocationId) {
const rmPrices = isRent ? RIGHTMOVE_RENTS : RIGHTMOVE_PRICES;
const rmParams = new URLSearchParams();
rmParams.set('searchLocation', postcode);
rmParams.set('useLocationIdentifier', 'true');
rmParams.set('locationIdentifier', rightmoveLocationId);
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
if (minPrice !== undefined)
rmParams.set('minPrice', String(snapToAllowed(minPrice, rmPrices, 'floor')));
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
if (maxPrice !== undefined)
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, rmPrices, 'ceil')));
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(Math.floor(minBedrooms)));
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(Math.ceil(maxBedrooms)));
if (minBathrooms !== undefined) rmParams.set('minBathrooms', String(Math.floor(minBathrooms)));
if (maxBathrooms !== undefined) rmParams.set('maxBathrooms', String(Math.ceil(maxBathrooms)));
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, RIGHTMOVE_PRICES, 'ceil')));
if (selectedTypes.length > 0) {
const rmTypes = [
...new Set(
@ -200,24 +145,22 @@ export function buildPropertySearchUrls({
];
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
}
if (!isRent && selectedTenures.length > 0) {
if (selectedTenures.length > 0) {
const rmTenures = selectedTenures.map((t) => (t === 'Freehold' ? 'FREEHOLD' : 'LEASEHOLD'));
rmParams.set('tenureTypes', rmTenures.join(','));
}
if (!isRent) rmParams.set('_includeSSTC', 'on');
const rmPath = isRent ? 'property-to-rent' : 'property-for-sale';
rightmove = `https://www.rightmove.co.uk/${rmPath}/find.html?${rmParams.toString()}`;
rmParams.set('_includeSSTC', 'on');
rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
}
// OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
const otmPrices = isRent ? OTM_RENTS : OTM_PRICES;
const otmParams = new URLSearchParams();
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
if (minPrice !== undefined)
otmParams.set('min-price', String(snapToAllowed(minPrice, otmPrices, 'floor')));
otmParams.set('min-price', String(snapToAllowed(minPrice, OTM_PRICES, 'floor')));
if (maxPrice !== undefined)
otmParams.set('max-price', String(snapToAllowed(maxPrice, otmPrices, 'ceil')));
otmParams.set('max-price', String(snapToAllowed(maxPrice, OTM_PRICES, 'ceil')));
if (selectedTypes.length > 0) {
const otmTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
@ -227,20 +170,17 @@ export function buildPropertySearchUrls({
}
}
otmParams.set('view', 'map-list');
const otmPath = isRent ? 'to-rent' : 'for-sale';
const onthemarket = `https://www.onthemarket.com/${otmPath}/property/${otmSlug}/?${otmParams.toString()}`;
const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
// Zoopla
const zPrices = isRent ? ZOOPLA_RENTS : ZOOPLA_PRICES;
const zParams = new URLSearchParams();
zParams.set('q', postcode);
const zSearchSource = isRent ? 'to-rent' : 'for-sale';
zParams.set('search_source', zSearchSource);
zParams.set('search_source', 'for-sale');
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
if (minPrice !== undefined)
zParams.set('price_min', String(snapToAllowed(minPrice, zPrices, 'floor')));
zParams.set('price_min', String(snapToAllowed(minPrice, ZOOPLA_PRICES, 'floor')));
if (maxPrice !== undefined)
zParams.set('price_max', String(snapToAllowed(maxPrice, zPrices, 'ceil')));
zParams.set('price_max', String(snapToAllowed(maxPrice, ZOOPLA_PRICES, 'ceil')));
if (selectedTypes.length > 0) {
const zTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
@ -249,28 +189,7 @@ export function buildPropertySearchUrls({
zParams.append('property_sub_type', zt!);
}
}
const zoopla = `https://www.zoopla.co.uk/${zSearchSource}/property/?${zParams.toString()}`;
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
// OpenRent — rent mode only
let openrent: string | null = null;
if (isRent) {
const postcodeNoSpaces = postcode.replace(/\s+/g, '');
const orSlug = postcodeNoSpaces.toLowerCase();
const orParams = new URLSearchParams();
orParams.set('term', postcodeNoSpaces.toUpperCase());
const radiusKm = Math.round((isPostcode ? 0.25 : radiusMiles) * 1.609);
orParams.set('area', String(Math.max(1, radiusKm)));
const rentFilter = filters['Asking rent (monthly)'];
const minRent =
Array.isArray(rentFilter) && typeof rentFilter[0] === 'number' ? rentFilter[0] : undefined;
const maxRent =
Array.isArray(rentFilter) && typeof rentFilter[1] === 'number' ? rentFilter[1] : undefined;
if (minRent !== undefined) orParams.set('prices_min', String(Math.round(minRent)));
if (maxRent !== undefined) orParams.set('prices_max', String(Math.round(maxRent)));
if (minBedrooms !== undefined) orParams.set('bedrooms_min', String(Math.floor(minBedrooms)));
if (maxBedrooms !== undefined) orParams.set('bedrooms_max', String(Math.ceil(maxBedrooms)));
openrent = `https://www.openrent.co.uk/properties-to-rent/${orSlug}?${orParams.toString()}`;
}
return { rightmove, onthemarket, zoopla, openrent };
return { rightmove, onthemarket, zoopla };
}

View file

@ -74,49 +74,6 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
</>
),
'Asking price': (
<>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</>
),
'Asking rent (monthly)': (
<>
<circle cx="9" cy="9" r="7" />
<path d="M15.58 8.42A7 7 0 0122 15a7 7 0 01-7 7 7 7 0 01-6.58-4.58" />
</>
),
Bedrooms: (
<>
<path d="M2 4v16" />
<path d="M2 8h18a2 2 0 012 2v10" />
<path d="M2 17h20" />
<path d="M6 4v4" />
</>
),
Bathrooms: (
<>
<path d="M4 12h16a1 1 0 011 1v3a4 4 0 01-4 4H7a4 4 0 01-4-4v-3a1 1 0 011-1z" />
<path d="M6 12V5a2 2 0 012-2h3" />
<line x1="14" y1="4" x2="17" y2="4" />
</>
),
'Listing date': (
<>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</>
),
'Listing status': (
<>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</>
),
'Leasehold/Freehold': (
<>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
@ -424,7 +381,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6" />
</>
),
'Number of parks within 2km': (
'Number of parks within 1km': (
<>
<path d="M12 22v-7" />
<path d="M17 15H7l2-4H5l7-9 7 9h-4l2 4z" />

View file

@ -46,6 +46,7 @@ export function parseInputValue(
export function formatDuration(d: string): string {
if (d === 'F' || d === 'L') {
// These are server enum values — translate via ts()
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
if (d === 'F') return ts('Freehold');
return ts('Leasehold');
@ -86,7 +87,10 @@ export function formatNumber(value: number | undefined, decimals = 0): string {
}
export function formatRelativeTime(isoDate: string): string {
const i18n = require('../i18n').default as { t: (key: string, opts?: Record<string, unknown>) => string };
// eslint-disable-next-line @typescript-eslint/no-var-requires
const i18n = require('../i18n').default as {
t: (key: string, opts?: Record<string, unknown>) => string;
};
const now = Date.now();
const then = new Date(isoDate).getTime();
const diffMs = now - then;

View file

@ -195,9 +195,12 @@ export function emojiToTwemojiUrl(emoji: string): string {
}
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
export function enumIndexToColor(index: number): [number, number, number] {
const i = Math.round(Math.max(0, index)) % ENUM_PALETTE.length;
return ENUM_PALETTE[i];
export function enumIndexToColor(
index: number,
palette: [number, number, number][] = ENUM_PALETTE
): [number, number, number] {
const i = Math.round(Math.max(0, index)) % palette.length;
return palette[i];
}
/**
@ -216,7 +219,8 @@ export function getFeatureFillColor(
densityGradient: GradientStop[],
isDark: boolean,
alpha: number,
enumCount: number = 0
enumCount: number = 0,
enumPalette?: [number, number, number][]
): [number, number, number, number] {
if (colorRange) {
if (value == null)
@ -232,9 +236,9 @@ export function getFeatureFillColor(
}
}
// Discrete coloring for enum features
// Discrete coloring for enum features (used as base; PieHexExtension overrides when active)
if (enumCount > 0) {
const rgb = enumIndexToColor(Math.round(value as number));
const rgb = enumIndexToColor(Math.round(value as number), enumPalette);
return [...rgb, alpha] as [number, number, number, number];
}

View file

@ -160,7 +160,11 @@ export function stateToParams(
}
export function summarizeParams(queryString: string): string {
const i18n = require('../i18n').default as { t: (key: string, opts?: Record<string, unknown>) => string };
// eslint-disable-next-line @typescript-eslint/no-var-requires
const i18n = require('../i18n').default as {
t: (key: string, opts?: Record<string, unknown>) => string;
};
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
const params = new URLSearchParams(queryString);
const parts: string[] = [];
@ -172,7 +176,7 @@ export function summarizeParams(queryString: string): string {
const colonIdx = entry.indexOf(':');
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
})
.filter((n) => n && n !== 'Listing status');
.filter((n) => n);
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2

View file

@ -9,6 +9,7 @@ export interface FeatureMeta {
histogram?: { min: number; max: number; p1: number; p99: number; counts: number[] };
// Enum-only fields
values?: string[];
counts?: Record<string, number>;
// Description fields
description?: string;
detail?: string;
@ -18,9 +19,6 @@ export interface FeatureMeta {
suffix?: string;
raw?: boolean;
absolute?: boolean;
// Mode restriction fields
modes?: string[];
linked?: string;
}
export interface FeatureGroup {
@ -132,10 +130,9 @@ export interface Property {
duration?: string;
current_energy_rating?: string;
potential_energy_rating?: string;
listing_status?: string;
listing_url?: string;
property_sub_type?: string;
price_qualifier?: string;
former_council_house?: string;
// Numeric fields
lat: number;
@ -143,7 +140,6 @@ export interface Property {
is_construction_date_approximate?: boolean;
renovation_history?: RenovationEvent[];
listing_features?: string[];
// All other numeric features (dynamic, including construction_age_band)
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,104 @@
import argparse
from pathlib import Path
import httpx
import polars as pl
# UK Parliament publishes candidate-level results for the 2024 General Election.
# One row per candidate per constituency — we aggregate to per-constituency stats.
URL = "https://electionresults.parliament.uk/general-elections/6/candidacies.csv"
# Map party names to a smaller set for the enum feature and vote share columns.
# Only parties that won seats in England are kept; the rest become "Other parties".
PARTY_MAP = {
"Labour": "Labour",
"Conservative": "Conservative",
"Liberal Democrat": "Liberal Democrat",
"Reform UK": "Reform UK",
"Green Party": "Green",
}
def download_and_convert(output_path: Path) -> None:
print("Downloading 2024 General Election results...")
response = httpx.get(URL, follow_redirects=True, timeout=60)
response.raise_for_status()
df = pl.read_csv(response.content)
print(f"Raw shape: {df.shape}")
# Filter to England only (constituency codes starting with E14)
df = df.filter(pl.col("Constituency geographic code").str.starts_with("E14"))
# Map party names to our output groups
df = df.with_columns(
pl.col("Main party name")
.replace_strict(PARTY_MAP, default="Other parties")
.alias("party_group"),
)
# ── Per-constituency winner stats ──
winners = df.filter(pl.col("Candidate result position") == 1).select(
pl.col("Constituency geographic code").alias("pcon"),
pl.col("party_group").alias("winning_party"),
(pl.col("Majority") / pl.col("Election valid vote count") * 100)
.round(1)
.alias("majority_pct"),
(pl.col("Election valid vote count") / pl.col("Electorate") * 100)
.round(1)
.alias("turnout_pct"),
)
# ── Per-party vote share percentages ──
# Sum votes per party group per constituency, then pivot to wide format
party_votes = (
df.group_by("Constituency geographic code", "party_group")
.agg(pl.col("Candidate vote count").sum())
.rename({"Constituency geographic code": "pcon"})
)
total_votes = (
df.group_by("Constituency geographic code")
.agg(pl.col("Candidate vote count").sum().alias("total_votes"))
.rename({"Constituency geographic code": "pcon"})
)
party_pct = (
party_votes.join(total_votes, on="pcon")
.with_columns(
(pl.col("Candidate vote count") / pl.col("total_votes") * 100)
.round(1)
.alias("vote_pct"),
)
.pivot(on="party_group", index="pcon", values="vote_pct")
)
# Rename columns to "% Party" format
rename_map = {col: f"% {col}" for col in party_pct.columns if col != "pcon"}
party_pct = party_pct.rename(rename_map)
# Join winner stats with party vote shares
result = winners.join(party_pct, on="pcon", how="left")
print(f"Constituencies: {result.height}")
print(f"Columns: {result.columns}")
print(
f"Party breakdown:\n{result['winning_party'].value_counts().sort('count', descending=True)}"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
result.write_parquet(output_path, compression="zstd")
print(f"Saved to {output_path}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Download 2024 General Election results by constituency"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
)
args = parser.parse_args()
download_and_convert(args.output)
if __name__ == "__main__":
main()

View file

@ -57,11 +57,33 @@ def download_and_convert(output_path: Path) -> None:
pl.col("Ethnicity").replace_strict(group_map).alias("group"),
)
# Sum percentages within each group per local authority
wide = (
detailed.group_by("Geography_code", "group")
.agg(pl.col("Value1").sum().round(1))
.pivot(on="group", index="Geography_code", values="Value1")
# Sum percentages within each group per local authority (keep full precision)
grouped = detailed.group_by("Geography_code", "group").agg(pl.col("Value1").sum())
wide = grouped.pivot(on="group", index="Geography_code", values="Value1")
# Normalize so each row sums to exactly 100%, then round using largest-remainder
# method to preserve the sum. Independent rounding of 6 values can drift ±0.3.
group_cols = [c for c in wide.columns if c != "Geography_code"]
row_total = sum(pl.col(c) for c in group_cols)
# Scale each group so they sum to exactly 100
wide = wide.with_columns(
[(pl.col(c) / row_total * 100.0).alias(c) for c in group_cols]
)
# Round to 1 decimal, then adjust the largest group to absorb residual
rounded_cols = [pl.col(c).round(1).alias(c) for c in group_cols]
wide = wide.with_columns(rounded_cols)
rounded_sum = sum(pl.col(c) for c in group_cols)
residual = (100.0 - rounded_sum).round(1)
# Find which group is largest per row and add the residual there
largest_col = pl.concat_list(group_cols).list.arg_max()
wide = wide.with_columns(
[
pl.when(largest_col == i)
.then(pl.col(c) + residual)
.otherwise(pl.col(c))
.alias(c)
for i, c in enumerate(group_cols)
]
)
# Rename columns to be descriptive

View file

@ -17,8 +17,8 @@ STOP_TYPES = {
"BCT": "Bus stop",
"BCE": "Bus station",
"TXR": "Taxi rank",
"TMU": "Metro or Tram stop",
"MET": "Metro or Tram stop",
"TMU": "Tube station",
"MET": "Tube station",
}

View file

@ -49,7 +49,7 @@ _AREA_COLUMNS = [
# Amenities
"Number of restaurants within 2km",
"Number of grocery shops and supermarkets within 2km",
"Number of parks within 2km",
"Number of parks within 1km",
"Distance to nearest train or tube station (km)",
"Distance to nearest park (km)",
# Environment
@ -62,6 +62,16 @@ _AREA_COLUMNS = [
"Good+ secondary schools within 2km",
# Demographics
"Median age",
# Politics
"Winning party",
"Voter turnout (%)",
"Majority (%)",
"% Labour",
"% Conservative",
"% Liberal Democrat",
"% Reform UK",
"% Green",
"% Other parties",
]
@ -78,6 +88,7 @@ def _build(
rental_prices_path: Path,
lsoa_population_path: Path,
median_age_path: Path,
election_results_path: Path,
) -> tuple[pl.DataFrame, pl.DataFrame]:
"""Build postcode and properties dataframes from epc_pp + auxiliary data.
@ -113,6 +124,7 @@ def _build(
pl.col("long").alias("lon"),
"lsoa21",
"oa21",
"pcon",
)
)
wide = wide.join(arcgis, on="postcode", how="left")
@ -193,6 +205,9 @@ def _build(
median_age = pl.scan_parquet(median_age_path)
wide = wide.join(median_age, on="lsoa21", how="left")
election = pl.scan_parquet(election_results_path)
wide = wide.join(election, on="pcon", how="left")
poi_counts = pl.scan_parquet(poi_proximity_path)
wide = wide.join(poi_counts, on="postcode", how="left")
@ -304,6 +319,7 @@ def _build(
"Barriers to Housing and Services Score",
"lsoa21",
"oa21",
"pcon",
"epc_property_type",
"pp_property_type",
"built_form",
@ -323,7 +339,7 @@ def _build(
"property_type": "Property type",
"restaurants_2km": "Number of restaurants within 2km",
"groceries_2km": "Number of grocery shops and supermarkets within 2km",
"parks_2km": "Number of parks within 2km",
"parks_1km": "Number of parks within 1km",
"train_tube_nearest_km": "Distance to nearest train or tube station (km)",
"parks_nearest_km": "Distance to nearest park (km)",
"latest_price": "Last known price",
@ -342,6 +358,9 @@ def _build(
"floor_height": "Interior height (m)",
"was_council_house": "Former council house",
"median_age": "Median age",
"winning_party": "Winning party",
"turnout_pct": "Voter turnout (%)",
"majority_pct": "Majority (%)",
}
)
)
@ -427,6 +446,12 @@ def main():
required=True,
help="Census 2021 median age by LSOA parquet file",
)
parser.add_argument(
"--election-results",
type=Path,
required=True,
help="2024 General Election results by constituency parquet file",
)
parser.add_argument(
"--output-postcodes",
type=Path,
@ -454,6 +479,7 @@ def main():
rental_prices_path=args.rental_prices,
lsoa_population_path=args.lsoa_population,
median_age_path=args.median_age,
election_results_path=args.election_results,
)
print(f"\nPostcode columns: {postcode_df.columns}")

View file

@ -17,7 +17,7 @@ POI_GROUPS_2KM = {
# Groups for which to compute distance to nearest POI (from filtered POIs)
DISTANCE_GROUPS = {
"train_tube": ["Metro or Tram stop", "Rail station"],
"train_tube": ["Tube station", "Rail station"],
}
# OS Open Greenspace function types used for park counts and distance calculation.
@ -67,8 +67,8 @@ def main():
# Park counts and distances from OS Open Greenspace
greenspace = pl.read_parquet(args.greenspace)
park_counts_2km = count_pois_per_postcode(
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS, radius_km=2
park_counts_1km = count_pois_per_postcode(
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS, radius_km=1
)
park_distances = min_distance_per_postcode(
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS
@ -77,7 +77,7 @@ def main():
# Join all results on postcode
result = (
counts_2km.join(distances, on="postcode")
.join(park_counts_2km, on="postcode")
.join(park_counts_1km, on="postcode")
.join(park_distances, on="postcode")
)

View file

@ -1054,7 +1054,7 @@ NAPTAN_EMOJIS: dict[str, str] = {
"Bus stop": "🚏",
"Bus station": "🚌",
"Taxi rank": "🚕",
"Metro or Tram stop": "🚊",
"Tube station": "🚇",
}

View file

@ -8,7 +8,7 @@ POI_GROUPS = {
"restaurants": ["Restaurant", "Fast Food"],
"groceries": ["Supermarket"],
"parks": ["Park"],
"train_tube": ["Rail station", "Metro or Tram stop"],
"train_tube": ["Rail station", "Tube station"],
}

View file

@ -43,13 +43,12 @@ dev = [
]
[tool.deptry]
# finder/ has its own pyproject.toml; analyses/ and scripts/ use transitive deps
exclude = ["\\.venv", "finder", "analyses", "scripts"]
# analyses/ and scripts/ use transitive deps
exclude = ["\\.venv", "analyses", "scripts"]
[tool.deptry.per_rule_ignores]
# pyarrow/fastexcel: runtime backends for polars parquet/Excel I/O
# jupyter/ipywidgets/pandas/plotly/folium: needed for analysis notebooks (excluded from scan)
# flask/fake-useragent: used in finder/ (excluded, has own pyproject.toml)
DEP002 = ["pyarrow", "fastexcel", "jupyter", "ipywidgets", "pandas", "plotly", "folium", "flask", "fake-useragent"]
DEP002 = ["pyarrow", "fastexcel", "jupyter", "ipywidgets", "pandas", "plotly", "folium"]
# pytest is a dev dependency, not a missing one
DEP004 = ["pytest"]

View file

@ -1,281 +0,0 @@
#!/usr/bin/env -S uv run --project ../finder
"""Zoopla scraping experiment — working prototype using Camoufox.
Key findings:
- Zoopla uses Cloudflare Turnstile (managed interactive challenge)
- Playwright headless Chromium + stealth patches CANNOT beat it
- Camoufox (anti-fingerprinting Firefox fork) PASSES Cloudflare
- Zoopla uses Next.js App Router with React Server Components (RSC)
- Listing data is NOT in __NEXT_DATA__ it's server-rendered in RSC stream
- URL-based location slugs (e.g. /properties/london/) return 0 results
- Must use the search autocomplete (GraphQL: getGeoSuggestion) to resolve
a location, then submit the form to get results
- GraphQL endpoint: api-graphql-lambda.prod.zoopla.co.uk/graphql
- Listings loaded via getTopLeadListingIds + getRareFindLeadListingIds ops
Usage:
uv run --project finder scripts/zoopla_experiment.py [LOCATION]
uv run --project finder scripts/zoopla_experiment.py "Tower Hamlets"
"""
import json
import logging
import re
import sys
import time
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("zoopla-exp")
def scrape_zoopla(location: str = "London", channel: str = "BUY"):
from camoufox.sync_api import Camoufox
tab_label = "Buy" if channel == "BUY" else "Rent"
log.info("Scraping Zoopla: location=%s channel=%s", location, channel)
with Camoufox(headless=True) as browser:
page = browser.new_page()
# Intercept GraphQL responses
graphql_responses = []
def on_resp(response):
url = response.url
ct = response.headers.get("content-type", "")
if "json" in ct and "graphql" in url:
try:
body = response.json()
req = response.request.post_data or ""
graphql_responses.append({"body": body, "req": req})
except Exception:
pass
page.on("response", on_resp)
# Step 1: Load homepage and pass Cloudflare
log.info("Loading Zoopla homepage...")
page.goto("https://www.zoopla.co.uk/", wait_until="domcontentloaded", timeout=60000)
for i in range(20):
if "Just a moment" not in page.title():
break
time.sleep(3)
else:
log.error("Cloudflare did not resolve after 60s")
return []
log.info("Homepage loaded: %s", page.title())
time.sleep(3)
# Step 2: Dismiss cookie consent (shadow DOM)
page.evaluate("""() => {
const aside = document.querySelector('#usercentrics-cmp-ui');
if (aside && aside.shadowRoot) {
const btns = aside.shadowRoot.querySelectorAll('button');
for (const btn of btns) {
if (btn.innerText.includes('Accept')) { btn.click(); return; }
}
}
aside?.remove();
}""")
time.sleep(2)
# Step 3: Select Buy/Rent tab if needed
if channel == "RENT":
rent_tab = page.query_selector('button:has-text("Rent")') or page.query_selector(f'[role="tab"]:has-text("{tab_label}")')
if rent_tab:
rent_tab.click()
time.sleep(1)
# Step 4: Type location into search and select autocomplete suggestion
log.info("Searching for '%s'...", location)
search_input = (
page.query_selector('input[name="autosuggest-input"]')
or page.query_selector('input[type="text"]')
)
if not search_input:
log.error("Could not find search input")
return []
search_input.click()
time.sleep(0.5)
search_input.fill("") # Clear any existing text
search_input.type(location, delay=80)
time.sleep(3)
# Select first autocomplete suggestion
first_option = page.query_selector('[role="option"]')
if first_option:
suggestion_text = first_option.inner_text()
log.info("Selecting suggestion: %s", suggestion_text)
first_option.click()
time.sleep(1)
else:
log.warning("No autocomplete suggestions appeared")
# Step 5: Submit search
search_btn = page.query_selector('button:has-text("Search")')
if search_btn:
search_btn.click()
else:
search_input.press("Enter")
log.info("Waiting for results...")
time.sleep(10)
final_url = page.url
final_title = page.title()
log.info("URL: %s", final_url)
log.info("Title: %s", final_title)
# Step 6: Extract listings from rendered DOM
listings = page.evaluate(r"""() => {
const links = Array.from(document.querySelectorAll(
'a[href*="/for-sale/details/"], a[href*="/new-homes/details/"], a[href*="/to-rent/details/"]'
));
const seen = new Set();
const results = [];
for (const link of links) {
const href = link.href;
const match = href.match(/\/details\/(\d+)\//);
if (!match) continue;
const id = match[1];
if (seen.has(id)) continue;
seen.add(id);
// Walk up to find the listing card container
let card = link;
for (let j = 0; j < 10; j++) {
card = card.parentElement;
if (!card) break;
const text = card.innerText || '';
// A listing card should have a price and at least beds or area
if (text.includes('£') && (text.includes('bed') || text.includes('sq ft'))) {
break;
}
}
if (!card) continue;
const text = card.innerText || '';
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
const priceMatch = text.match(/£([\d,]+)/);
const bedsMatch = text.match(/(\d+)\s*beds?/i);
const bathsMatch = text.match(/(\d+)\s*baths?/i);
const recMatch = text.match(/(\d+)\s*reception/i);
const areaMatch = text.match(/([\d,]+)\s*sq\s*ft/i);
// Try to find address usually a line with a postcode or comma-separated location
let address = '';
for (const line of lines) {
if (/[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}/i.test(line) ||
(line.includes(',') && !line.includes('£') && !line.match(/^\d+ beds?/i))) {
address = line;
break;
}
}
// Tenure
let tenure = '';
if (/freehold/i.test(text)) tenure = 'Freehold';
else if (/leasehold/i.test(text)) tenure = 'Leasehold';
results.push({
id: id,
url: href.replace(window.location.origin, ''),
price: priceMatch ? parseInt(priceMatch[1].replace(/,/g, '')) : null,
beds: bedsMatch ? parseInt(bedsMatch[1]) : null,
baths: bathsMatch ? parseInt(bathsMatch[1]) : null,
receptions: recMatch ? parseInt(recMatch[1]) : null,
floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
address: address,
tenure: tenure,
text_preview: lines.slice(0, 10).join(' | '),
});
}
return results;
}""")
log.info("Extracted %d unique listings from page 1", len(listings))
# Step 7: Check for results count and pagination
body_text = page.inner_text("body")
count_match = re.search(r"([\d,]+)\s+results?", body_text)
total_results = int(count_match.group(1).replace(",", "")) if count_match else len(listings)
log.info("Total results: %d", total_results)
# Step 8: Log GraphQL operations we saw
log.info("GraphQL operations intercepted:")
for gql in graphql_responses:
try:
req = json.loads(gql["req"])
op = req.get("operationName", "?")
log.info(" - %s", op)
except Exception:
pass
# Step 9: Extract cookies for potential curl_cffi reuse
cookies = page.context.cookies()
session_cookies = {
c["name"]: c["value"]
for c in cookies
if "zoopla" in c.get("domain", "") or "cf" in c.get("name", "").lower()
}
ua = page.evaluate("navigator.userAgent")
return {
"url": final_url,
"title": final_title,
"total_results": total_results,
"listings": listings,
"cookies": session_cookies,
"user_agent": ua,
}
def main():
location = sys.argv[1] if len(sys.argv) > 1 else "London"
result = scrape_zoopla(location, channel="BUY")
if not result:
log.error("Scraping failed")
sys.exit(1)
listings = result["listings"]
print(f"\n{'='*60}")
print(f" Zoopla: {result['title']}")
print(f" URL: {result['url']}")
print(f" Total: {result['total_results']} results, {len(listings)} extracted")
print(f"{'='*60}\n")
for i, listing in enumerate(listings):
print(f"--- Listing {i+1}: {listing['url']} ---")
display = {k: v for k, v in listing.items() if k != "text_preview" and v}
print(json.dumps(display, indent=2, ensure_ascii=False))
print()
# Summary stats
prices = [item["price"] for item in listings if item["price"]]
beds = [item["beds"] for item in listings if item["beds"]]
if prices:
print(f"Price range: £{min(prices):,} - £{max(prices):,}")
print(f"Median: £{sorted(prices)[len(prices)//2]:,}")
if beds:
print(f"Bedrooms: {min(beds)}-{max(beds)}")
# Cookie info for reuse
print(f"\nSession cookies ({len(result['cookies'])} cookies)")
print(f"User-Agent: {result['user_agent']}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,13 @@
use crate::consts::NAN_U16;
use crate::data::QuantRef;
/// Optional per-enum-value distribution tracking for a single feature.
/// Counts how many rows have each enum value (by raw u16 index).
pub struct EnumDist {
pub feat_idx: usize,
pub counts: Box<[u32]>,
}
/// Per-cell accumulator for aggregating features (min/max/sum/count).
/// Uses Box<[T]> instead of Vec<T> to avoid storing capacity (saves 8 bytes per field per cell).
/// Shared by hexagon and postcode aggregation routes.
@ -10,16 +17,26 @@ pub struct Aggregator {
pub maxs: Box<[f32]>,
pub sums: Box<[f64]>,
pub feat_counts: Box<[u32]>,
/// Optional: per-value counts for a single enum feature (for pie chart visualization).
pub enum_dist: Option<EnumDist>,
}
/// Configuration for enum distribution tracking, passed to Aggregator::new.
/// (feature_index, number_of_enum_values)
pub type EnumDistConfig = Option<(usize, usize)>;
impl Aggregator {
pub fn new(num_features: usize) -> Self {
pub fn new(num_features: usize, enum_dist_config: EnumDistConfig) -> Self {
Aggregator {
count: 0,
mins: vec![f32::INFINITY; num_features].into_boxed_slice(),
maxs: vec![f32::NEG_INFINITY; num_features].into_boxed_slice(),
sums: vec![0.0f64; num_features].into_boxed_slice(),
feat_counts: vec![0u32; num_features].into_boxed_slice(),
enum_dist: enum_dist_config.map(|(feat_idx, num_values)| EnumDist {
feat_idx,
counts: vec![0u32; num_values].into_boxed_slice(),
}),
}
}
@ -50,6 +67,17 @@ impl Aggregator {
self.feat_counts[feat_index] += 1;
}
}
// Enum distribution: single branch per row (not per feature).
// Uses raw u16 directly — enum features are stored as u16 indices.
if let Some(ref mut ed) = self.enum_dist {
let raw = row_slice[ed.feat_idx];
if raw != NAN_U16 {
let idx = raw as usize;
if idx < ed.counts.len() {
ed.counts[idx] += 1;
}
}
}
}
/// Merge another aggregator's results into this one.
@ -67,6 +95,12 @@ impl Aggregator {
self.feat_counts[i] += other.feat_counts[i];
}
}
// Merge enum distribution counts
if let (Some(ref mut mine), Some(ref theirs)) = (&mut self.enum_dist, &other.enum_dist) {
for (m, t) in mine.counts.iter_mut().zip(theirs.counts.iter()) {
*m += t;
}
}
}
/// Add a row, only aggregating the features at the given indices.
@ -95,5 +129,15 @@ impl Aggregator {
self.feat_counts[feat_index] += 1;
}
}
// Enum distribution (same raw u16 approach)
if let Some(ref mut ed) = self.enum_dist {
let raw = feature_data[base + ed.feat_idx];
if raw != NAN_U16 {
let idx = raw as usize;
if idx < ed.counts.len() {
ed.counts[idx] += 1;
}
}
}
}
}

View file

@ -83,7 +83,10 @@ impl OutcodeData {
})
.collect();
info!(outcodes = names.len(), "Outcode data derived from postcodes");
info!(
outcodes = names.len(),
"Outcode data derived from postcodes"
);
OutcodeData {
names,

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