Morning improvements
This commit is contained in:
parent
3e9fba5303
commit
53fff3efaa
41 changed files with 2438 additions and 637 deletions
|
|
@ -32,6 +32,9 @@ SCRAPE_OPENRENT = os.environ.get("SCRAPE_OPENRENT", "true").lower() in (
|
||||||
"yes",
|
"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"
|
TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
|
||||||
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
|
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
|
||||||
RIGHTMOVE_BASE = "https://www.rightmove.co.uk"
|
RIGHTMOVE_BASE = "https://www.rightmove.co.uk"
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,6 @@ dependencies = [
|
||||||
"prometheus-client",
|
"prometheus-client",
|
||||||
"beautifulsoup4",
|
"beautifulsoup4",
|
||||||
"playwright>=1.58.0",
|
"playwright>=1.58.0",
|
||||||
|
"playwright-stealth>=2.0.2",
|
||||||
|
"camoufox>=0.4.11",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@ from dataclasses import dataclass, field
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from constants import (
|
from constants import (
|
||||||
ARCGIS_PATH,
|
ARCGIS_PATH,
|
||||||
CHANNELS,
|
CHANNELS,
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
DELAY_BETWEEN_OUTCODES,
|
DELAY_BETWEEN_OUTCODES,
|
||||||
|
RELOAD_URL,
|
||||||
SCRAPE_HOMECOUK,
|
SCRAPE_HOMECOUK,
|
||||||
SCRAPE_OPENRENT,
|
SCRAPE_OPENRENT,
|
||||||
SCRAPE_RIGHTMOVE,
|
SCRAPE_RIGHTMOVE,
|
||||||
|
|
@ -151,6 +154,15 @@ def build_postcode_coords() -> dict[str, tuple[float, float]]:
|
||||||
return 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:
|
def _dedup_key(p: dict) -> tuple:
|
||||||
"""Composite key for cross-source deduplication: (postcode, bedrooms, price).
|
"""Composite key for cross-source deduplication: (postcode, bedrooms, price).
|
||||||
Two listings on different portals for the same physical property will share
|
Two listings on different portals for the same physical property will share
|
||||||
|
|
@ -253,6 +265,8 @@ def run_scrape(
|
||||||
status.hk_properties = 0
|
status.hk_properties = 0
|
||||||
status.or_properties = 0
|
status.or_properties = 0
|
||||||
|
|
||||||
|
channel_start = time.time()
|
||||||
|
prev_prop_milestone = 0 # last 10k milestone we logged
|
||||||
log.info(
|
log.info(
|
||||||
"=== Starting %s channel (%d outcodes) ===", channel_name, len(shuffled)
|
"=== Starting %s channel (%d outcodes) ===", channel_name, len(shuffled)
|
||||||
)
|
)
|
||||||
|
|
@ -262,14 +276,6 @@ def run_scrape(
|
||||||
status.outcode = outcode
|
status.outcode = outcode
|
||||||
status.outcodes_done = i
|
status.outcodes_done = i
|
||||||
|
|
||||||
log.debug(
|
|
||||||
"Outcode %s (%d/%d) — %d properties so far",
|
|
||||||
outcode,
|
|
||||||
i + 1,
|
|
||||||
len(shuffled),
|
|
||||||
len(all_properties),
|
|
||||||
)
|
|
||||||
|
|
||||||
made_requests = False
|
made_requests = False
|
||||||
|
|
||||||
# --- Rightmove ---
|
# --- Rightmove ---
|
||||||
|
|
@ -416,14 +422,38 @@ def run_scrape(
|
||||||
status.or_properties = or_count
|
status.or_properties = or_count
|
||||||
_sync_gauges()
|
_sync_gauges()
|
||||||
|
|
||||||
log.info(
|
# Log progress every 100 outcodes
|
||||||
"Outcode %s: total %d (rm: %d, hk: %d, or: %d)",
|
done = i + 1
|
||||||
outcode,
|
elapsed = time.time() - channel_start
|
||||||
len(all_properties),
|
if done % 100 == 0 or done == len(shuffled):
|
||||||
rm_count,
|
pct = done * 100 // len(shuffled)
|
||||||
hk_count,
|
rate = done / elapsed if elapsed > 0 else 0
|
||||||
or_count,
|
log.info(
|
||||||
)
|
"%s %d/%d (%d%%) — %d props, %s elapsed, %.1f outcodes/min",
|
||||||
|
channel_name,
|
||||||
|
done,
|
||||||
|
len(shuffled),
|
||||||
|
pct,
|
||||||
|
len(all_properties),
|
||||||
|
_fmt_elapsed(elapsed),
|
||||||
|
rate * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log when crossing a 10k property milestone
|
||||||
|
current_milestone = len(all_properties) // 10_000
|
||||||
|
if current_milestone > prev_prop_milestone:
|
||||||
|
prev_prop_milestone = current_milestone
|
||||||
|
log.info(
|
||||||
|
"%s %dk properties (rm: %d, hk: %d, or: %d) at outcode %d/%d [%s]",
|
||||||
|
channel_name,
|
||||||
|
current_milestone * 10,
|
||||||
|
rm_count,
|
||||||
|
hk_count,
|
||||||
|
or_count,
|
||||||
|
done,
|
||||||
|
len(shuffled),
|
||||||
|
_fmt_elapsed(elapsed),
|
||||||
|
)
|
||||||
|
|
||||||
if made_requests and i < len(shuffled) - 1:
|
if made_requests and i < len(shuffled) - 1:
|
||||||
time.sleep(DELAY_BETWEEN_OUTCODES)
|
time.sleep(DELAY_BETWEEN_OUTCODES)
|
||||||
|
|
@ -457,12 +487,30 @@ def run_scrape(
|
||||||
_sync_gauges()
|
_sync_gauges()
|
||||||
elapsed = status.finished_at - status.started_at
|
elapsed = status.finished_at - status.started_at
|
||||||
log.info(
|
log.info(
|
||||||
"Scrape complete in %.0fs — buy: %d, rent: %d",
|
"Scrape complete in %s — buy: %d, rent: %d",
|
||||||
elapsed,
|
_fmt_elapsed(elapsed),
|
||||||
status.properties_buy,
|
status.properties_buy,
|
||||||
status.properties_rent,
|
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:
|
except Exception as e:
|
||||||
log.exception("Fatal scrape error")
|
log.exception("Fatal scrape error")
|
||||||
with status_lock:
|
with status_lock:
|
||||||
|
|
|
||||||
515
finder/uv.lock
generated
515
finder/uv.lock
generated
|
|
@ -15,6 +15,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "beautifulsoup4"
|
name = "beautifulsoup4"
|
||||||
version = "4.14.3"
|
version = "4.14.3"
|
||||||
|
|
@ -37,6 +46,45 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.2.25"
|
version = "2026.2.25"
|
||||||
|
|
@ -103,6 +151,79 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.1"
|
version = "8.3.1"
|
||||||
|
|
@ -147,6 +268,19 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fake-useragent"
|
name = "fake-useragent"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
|
|
@ -162,11 +296,13 @@ version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "beautifulsoup4" },
|
{ name = "beautifulsoup4" },
|
||||||
|
{ name = "camoufox" },
|
||||||
{ name = "curl-cffi" },
|
{ name = "curl-cffi" },
|
||||||
{ name = "fake-useragent" },
|
{ name = "fake-useragent" },
|
||||||
{ name = "flask" },
|
{ name = "flask" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "playwright" },
|
{ name = "playwright" },
|
||||||
|
{ name = "playwright-stealth" },
|
||||||
{ name = "polars" },
|
{ name = "polars" },
|
||||||
{ name = "prometheus-client" },
|
{ name = "prometheus-client" },
|
||||||
]
|
]
|
||||||
|
|
@ -174,11 +310,13 @@ dependencies = [
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "beautifulsoup4" },
|
{ name = "beautifulsoup4" },
|
||||||
|
{ name = "camoufox", specifier = ">=0.4.11" },
|
||||||
{ name = "curl-cffi" },
|
{ name = "curl-cffi" },
|
||||||
{ name = "fake-useragent", specifier = ">=2.2.0" },
|
{ name = "fake-useragent", specifier = ">=2.2.0" },
|
||||||
{ name = "flask" },
|
{ name = "flask" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "playwright", specifier = ">=1.58.0" },
|
{ name = "playwright", specifier = ">=1.58.0" },
|
||||||
|
{ name = "playwright-stealth", specifier = ">=2.0.2" },
|
||||||
{ name = "polars" },
|
{ name = "polars" },
|
||||||
{ name = "prometheus-client" },
|
{ name = "prometheus-client" },
|
||||||
]
|
]
|
||||||
|
|
@ -310,6 +448,95 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markupsafe"
|
name = "markupsafe"
|
||||||
version = "3.0.3"
|
version = "3.0.3"
|
||||||
|
|
@ -373,6 +600,129 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "playwright"
|
name = "playwright"
|
||||||
version = "1.58.0"
|
version = "1.58.0"
|
||||||
|
|
@ -392,6 +742,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "polars"
|
name = "polars"
|
||||||
version = "1.39.0"
|
version = "1.39.0"
|
||||||
|
|
@ -450,6 +812,118 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "soupsieve"
|
name = "soupsieve"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
|
|
@ -459,6 +933,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|
@ -468,6 +954,35 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.6"
|
version = "3.1.6"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import Header, { type Page } from './components/ui/Header';
|
||||||
import AuthModal from './components/ui/AuthModal';
|
import AuthModal from './components/ui/AuthModal';
|
||||||
import SaveSearchModal from './components/ui/SaveSearchModal';
|
import SaveSearchModal from './components/ui/SaveSearchModal';
|
||||||
import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
|
import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
|
||||||
import VerificationBanner from './components/ui/VerificationBanner';
|
|
||||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||||
import { fetchWithRetry, apiUrl } from './lib/api';
|
import { fetchWithRetry, apiUrl } from './lib/api';
|
||||||
import { trackEvent } from './lib/analytics';
|
import { trackEvent } from './lib/analytics';
|
||||||
|
|
@ -118,15 +117,12 @@ export default function App() {
|
||||||
loginWithOAuth,
|
loginWithOAuth,
|
||||||
logout,
|
logout,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
requestVerification,
|
|
||||||
refreshAuth,
|
refreshAuth,
|
||||||
clearError,
|
clearError,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||||
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
||||||
const [showLicenseSuccess, setShowLicenseSuccess] = useState(false);
|
const [showLicenseSuccess, setShowLicenseSuccess] = useState(false);
|
||||||
const [verificationDismissed, setVerificationDismissed] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('license_success') === '1') {
|
if (params.get('license_success') === '1') {
|
||||||
|
|
@ -304,13 +300,6 @@ export default function App() {
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
{user && !user.verified && !verificationDismissed && isAuthRequiredPage && (
|
|
||||||
<VerificationBanner
|
|
||||||
email={user.email}
|
|
||||||
onRequestVerification={requestVerification}
|
|
||||||
onDismiss={() => setVerificationDismissed(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activePage === 'home' ? (
|
{activePage === 'home' ? (
|
||||||
<HomePage
|
<HomePage
|
||||||
onOpenDashboard={() => navigateTo('dashboard')}
|
onOpenDashboard={() => navigateTo('dashboard')}
|
||||||
|
|
@ -357,7 +346,6 @@ export default function App() {
|
||||||
<AccountPage
|
<AccountPage
|
||||||
user={user}
|
user={user}
|
||||||
onRefreshAuth={refreshAuth}
|
onRefreshAuth={refreshAuth}
|
||||||
onRequestVerification={requestVerification}
|
|
||||||
/>
|
/>
|
||||||
) : activePage === 'invite' && inviteCode ? (
|
) : activePage === 'invite' && inviteCode ? (
|
||||||
<InvitePage
|
<InvitePage
|
||||||
|
|
|
||||||
|
|
@ -746,18 +746,13 @@ export function InvitesPage({ user }: { user: AuthUser }) {
|
||||||
export default function AccountPage({
|
export default function AccountPage({
|
||||||
user,
|
user,
|
||||||
onRefreshAuth,
|
onRefreshAuth,
|
||||||
onRequestVerification,
|
|
||||||
}: {
|
}: {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onRefreshAuth: () => Promise<void>;
|
onRefreshAuth: () => Promise<void>;
|
||||||
onRequestVerification: (email: string) => Promise<void>;
|
|
||||||
}) {
|
}) {
|
||||||
const [newsletterSaving, setNewsletterSaving] = useState(false);
|
const [newsletterSaving, setNewsletterSaving] = useState(false);
|
||||||
const [newsletterError, setNewsletterError] = useState<string | null>(null);
|
const [newsletterError, setNewsletterError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [verificationSending, setVerificationSending] = useState(false);
|
|
||||||
const [verificationSent, setVerificationSent] = useState(false);
|
|
||||||
|
|
||||||
const badgeColor =
|
const badgeColor =
|
||||||
user.subscription === 'licensed'
|
user.subscription === 'licensed'
|
||||||
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
|
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
|
||||||
|
|
@ -773,38 +768,6 @@ export default function AccountPage({
|
||||||
<p className="text-sm text-warm-500 dark:text-warm-400">Email</p>
|
<p className="text-sm text-warm-500 dark:text-warm-400">Email</p>
|
||||||
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
|
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{!user.verified && (
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
setVerificationSending(true);
|
|
||||||
try {
|
|
||||||
await onRequestVerification(user.email);
|
|
||||||
setVerificationSent(true);
|
|
||||||
setTimeout(() => setVerificationSent(false), 3000);
|
|
||||||
} catch {
|
|
||||||
// Error handled by hook
|
|
||||||
} finally {
|
|
||||||
setVerificationSending(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={verificationSending || verificationSent}
|
|
||||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 disabled:opacity-50 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{verificationSending && <SpinnerIcon className="w-3 h-3 animate-spin" />}
|
|
||||||
{verificationSent ? 'Sent!' : 'Resend verification'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
|
||||||
user.verified
|
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
|
||||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{user.verified ? 'Verified' : 'Unverified'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subscription */}
|
{/* Subscription */}
|
||||||
|
|
|
||||||
|
|
@ -170,12 +170,6 @@ export default memo(function AiFilterInput({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && errorType === 'verification' && (
|
|
||||||
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
Please verify your email address to use AI-powered search. Check your inbox for a
|
|
||||||
verification link.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{error && errorType === 'limit' && (
|
{error && errorType === 'limit' && (
|
||||||
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||||
You've reached the weekly AI usage limit. It will reset automatically next week.
|
You've reached the weekly AI usage limit. It will reset automatically next week.
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export default function FeatureBrowser({
|
||||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 mr-2">
|
<div className="min-w-0 mr-2">
|
||||||
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
|
<FeatureLabel feature={f} size="sm" />
|
||||||
{f.description && (
|
{f.description && (
|
||||||
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
|
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
|
||||||
{f.description}
|
{f.description}
|
||||||
|
|
@ -145,6 +145,7 @@ export default function FeatureBrowser({
|
||||||
feature={f}
|
feature={f}
|
||||||
isPinned={isPinned}
|
isPinned={isPinned}
|
||||||
onTogglePin={onTogglePin}
|
onTogglePin={onTogglePin}
|
||||||
|
onShowInfo={setInfoFeature}
|
||||||
onAdd={onAddFilter}
|
onAdd={onAddFilter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,11 @@ import { memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||||
import { Slider } from '../ui/Slider';
|
import { Slider } from '../ui/Slider';
|
||||||
import { LightbulbIcon } from '../ui/icons';
|
import { LightbulbIcon } from '../ui/icons';
|
||||||
|
|
||||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
|
||||||
import { PillToggle } from '../ui/PillToggle';
|
import { PillToggle } from '../ui/PillToggle';
|
||||||
import { PillGroup } from '../ui/PillGroup';
|
import { PillGroup } from '../ui/PillGroup';
|
||||||
import type { FeatureMeta, FeatureFilters } from '../../types';
|
import type { FeatureMeta, FeatureFilters } from '../../types';
|
||||||
import { formatFilterValue, buildPercentileScale } from '../../lib/format';
|
import { formatFilterValue, buildPercentileScale } from '../../lib/format';
|
||||||
import type { PercentileScale } from '../../lib/format';
|
import type { PercentileScale } from '../../lib/format';
|
||||||
import { groupFeaturesByCategory } from '../../lib/features';
|
|
||||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
|
||||||
import InfoPopup from '../ui/InfoPopup';
|
import InfoPopup from '../ui/InfoPopup';
|
||||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||||
import { FeatureActions } from '../ui/FeatureIcons';
|
import { FeatureActions } from '../ui/FeatureIcons';
|
||||||
|
|
@ -249,29 +246,24 @@ export default memo(function Filters({
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||||
const [collapsedGroups, toggleGroup, expandGroup] = useCollapsibleGroups();
|
|
||||||
|
|
||||||
const activeEntryCount = travelTimeEntries.length;
|
const activeEntryCount = travelTimeEntries.length;
|
||||||
|
|
||||||
const pendingScrollRef = useRef<string | null>(null);
|
const pendingScrollRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const handleAddAndScroll = useCallback(
|
const handleAddAndScroll = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
const feature = features.find((f) => f.name === name);
|
|
||||||
if (feature?.group) expandGroup(feature.group);
|
|
||||||
pendingScrollRef.current = name;
|
pendingScrollRef.current = name;
|
||||||
onAddFilter(name);
|
onAddFilter(name);
|
||||||
},
|
},
|
||||||
[onAddFilter, features, expandGroup]
|
[onAddFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddTravelTimeAndScroll = useCallback(
|
const handleAddTravelTimeAndScroll = useCallback(
|
||||||
(mode: TransportMode) => {
|
(mode: TransportMode) => {
|
||||||
expandGroup('Transport');
|
|
||||||
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
|
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
|
||||||
onTravelTimeAddEntry(mode);
|
onTravelTimeAddEntry(mode);
|
||||||
},
|
},
|
||||||
[onTravelTimeAddEntry, travelTimeEntries.length, expandGroup]
|
[onTravelTimeAddEntry, travelTimeEntries.length]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -283,21 +275,6 @@ export default memo(function Filters({
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
}, [enabledFeatureList, travelTimeEntries]);
|
}, [enabledFeatureList, travelTimeEntries]);
|
||||||
const enabledGroups = useMemo(
|
|
||||||
() => groupFeaturesByCategory(enabledFeatureList),
|
|
||||||
[enabledFeatureList]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ensure "Transport" group exists in active filters when travel time entries are present
|
|
||||||
const mergedGroups = useMemo(() => {
|
|
||||||
if (travelTimeEntries.length === 0) return enabledGroups;
|
|
||||||
if (enabledGroups.some((g) => g.name === 'Transport')) return enabledGroups;
|
|
||||||
const groups = [...enabledGroups];
|
|
||||||
const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area');
|
|
||||||
groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] });
|
|
||||||
return groups;
|
|
||||||
}, [enabledGroups, travelTimeEntries.length]);
|
|
||||||
|
|
||||||
const percentileScales = useMemo(() => {
|
const percentileScales = useMemo(() => {
|
||||||
const scales = new Map<string, PercentileScale>();
|
const scales = new Map<string, PercentileScale>();
|
||||||
for (const f of features) {
|
for (const f of features) {
|
||||||
|
|
@ -313,7 +290,7 @@ export default memo(function Filters({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full"
|
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full touch-pan-y"
|
||||||
>
|
>
|
||||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
|
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
|
||||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||||
|
|
@ -374,182 +351,159 @@ export default memo(function Filters({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mergedGroups.map((group) => {
|
<div className="px-2 py-1 space-y-1">
|
||||||
const isExpanded = !collapsedGroups.has(group.name);
|
{travelTimeEntries.map((entry, index) => (
|
||||||
const isTransport = group.name === 'Transport';
|
<div
|
||||||
const groupCount = group.features.length + (isTransport ? travelTimeEntries.length : 0);
|
key={`tt_${index}`}
|
||||||
return (
|
data-filter-name={`tt_${index}`}
|
||||||
<div key={group.name}>
|
>
|
||||||
<CollapsibleGroupHeader
|
<TravelTimeCard
|
||||||
name={group.name}
|
mode={entry.mode}
|
||||||
expanded={isExpanded}
|
slug={entry.slug}
|
||||||
onToggle={() => toggleGroup(group.name)}
|
label={entry.label}
|
||||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
timeRange={entry.timeRange}
|
||||||
>
|
useBest={entry.useBest}
|
||||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||||
{groupCount}
|
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||||
</span>
|
onSetDestination={(slug, label) =>
|
||||||
</CollapsibleGroupHeader>
|
onTravelTimeSetDestination(index, slug, label)
|
||||||
{isExpanded && (
|
}
|
||||||
<div className="px-2 py-1 space-y-1">
|
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||||
{isTransport &&
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
travelTimeEntries.map((entry, index) => (
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
<div
|
/>
|
||||||
key={`tt_${index}`}
|
|
||||||
data-filter-name={`tt_${index}`}
|
|
||||||
className="scroll-mt-10"
|
|
||||||
>
|
|
||||||
<TravelTimeCard
|
|
||||||
mode={entry.mode}
|
|
||||||
slug={entry.slug}
|
|
||||||
label={entry.label}
|
|
||||||
timeRange={entry.timeRange}
|
|
||||||
useBest={entry.useBest}
|
|
||||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
|
||||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
|
||||||
onSetDestination={(slug, label) =>
|
|
||||||
onTravelTimeSetDestination(index, slug, label)
|
|
||||||
}
|
|
||||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
|
||||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
|
||||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{group.features.map((feature) => {
|
|
||||||
if (feature.type === 'enum') {
|
|
||||||
const selectedValues = (filters[feature.name] as string[]) || [];
|
|
||||||
const allValues = feature.values || [];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={feature.name}
|
|
||||||
data-filter-name={feature.name}
|
|
||||||
className={`scroll-mt-10 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={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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = activeFeature === feature.name;
|
|
||||||
const isPinned = pinnedFeature === feature.name;
|
|
||||||
const hist = feature.histogram;
|
|
||||||
const displayValue =
|
|
||||||
isActive && dragValue
|
|
||||||
? dragValue
|
|
||||||
: (filters[feature.name] as [number, number]) || [
|
|
||||||
hist?.min ?? feature.min!,
|
|
||||||
hist?.max ?? feature.max!,
|
|
||||||
];
|
|
||||||
const scale = percentileScales.get(feature.name);
|
|
||||||
const dataMin = hist?.min ?? feature.min!;
|
|
||||||
const dataMax = hist?.max ?? feature.max!;
|
|
||||||
const isAtMin = displayValue[0] <= dataMin;
|
|
||||||
const isAtMax = displayValue[1] >= dataMax;
|
|
||||||
const sliderValue: [number, number] = scale
|
|
||||||
? [
|
|
||||||
isAtMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
|
||||||
isAtMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
isAtMin ? feature.min! : displayValue[0],
|
|
||||||
isAtMax ? feature.max! : displayValue[1],
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={feature.name}
|
|
||||||
data-filter-name={feature.name}
|
|
||||||
className={`scroll-mt-10 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 gap-1">
|
|
||||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" />
|
|
||||||
<FeatureActions
|
|
||||||
feature={feature}
|
|
||||||
isPinned={isPinned}
|
|
||||||
onTogglePin={onTogglePin}
|
|
||||||
onShowInfo={setActiveInfoFeature}
|
|
||||||
onRemove={onRemoveFilter}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<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={scale ? displayValue : undefined}
|
|
||||||
isAtMin={isAtMin}
|
|
||||||
isAtMax={isAtMax}
|
|
||||||
raw={feature.raw}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
{enabledFeatureList.map((feature) => {
|
||||||
|
if (feature.type === 'enum') {
|
||||||
|
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||||
|
const allValues = feature.values || [];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.name}
|
||||||
|
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={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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = activeFeature === feature.name;
|
||||||
|
const isPinned = pinnedFeature === feature.name;
|
||||||
|
const hist = feature.histogram;
|
||||||
|
const displayValue =
|
||||||
|
isActive && dragValue
|
||||||
|
? dragValue
|
||||||
|
: (filters[feature.name] as [number, number]) || [
|
||||||
|
hist?.min ?? feature.min!,
|
||||||
|
hist?.max ?? feature.max!,
|
||||||
|
];
|
||||||
|
const scale = percentileScales.get(feature.name);
|
||||||
|
const dataMin = hist?.min ?? feature.min!;
|
||||||
|
const dataMax = hist?.max ?? feature.max!;
|
||||||
|
const isAtMin = displayValue[0] <= dataMin;
|
||||||
|
const isAtMax = displayValue[1] >= dataMax;
|
||||||
|
const sliderValue: [number, number] = scale
|
||||||
|
? [
|
||||||
|
isAtMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||||
|
isAtMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
isAtMin ? feature.min! : displayValue[0],
|
||||||
|
isAtMax ? feature.max! : displayValue[1],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.name}
|
||||||
|
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="flex items-center justify-between gap-1">
|
||||||
|
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" />
|
||||||
|
<FeatureActions
|
||||||
|
feature={feature}
|
||||||
|
isPinned={isPinned}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
onShowInfo={setActiveInfoFeature}
|
||||||
|
onRemove={onRemoveFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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={scale ? displayValue : undefined}
|
||||||
|
isAtMin={isAtMin}
|
||||||
|
isAtMax={isAtMax}
|
||||||
|
raw={feature.raw}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[60%] border-t border-warm-200 dark:border-warm-700">
|
<div className="shrink-0 md:shrink md:min-h-0 hidden md:flex flex-col md:basis-[60%] border-t border-warm-200 dark:border-warm-700">
|
||||||
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
|
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -76,13 +76,14 @@ function nextMondayAt730(): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
function googleMapsUrl(postcode: string, destination: string): string {
|
function googleMapsUrl(postcode: string, destination: string): string {
|
||||||
const params = new URLSearchParams({
|
const ts = nextMondayAt730();
|
||||||
api: '1',
|
const origin = encodeURIComponent(postcode);
|
||||||
origin: postcode,
|
const dest = encodeURIComponent(destination);
|
||||||
destination,
|
// The official api=1 URL scheme doesn't support departure_time.
|
||||||
travelmode: 'transit',
|
// Use the undocumented data= path parameter with protobuf-like encoding:
|
||||||
});
|
// !3e3 = transit, !6e0 = "depart at", !7e2 = local time, !8j = timestamp
|
||||||
return `https://www.google.com/maps/dir/?${params}&departure_time=${nextMondayAt730()}`;
|
const data = `!4m6!4m5!2m3!6e0!7e2!8j${ts}!3e3`;
|
||||||
|
return `https://www.google.com/maps/dir/${origin}/${dest}/data=${data}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
|
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||||
import MapLegend from './MapLegend';
|
import MapLegend from './MapLegend';
|
||||||
import HoverCard from './HoverCard';
|
import HoverCard from './HoverCard';
|
||||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||||
|
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||||
import type { FeatureFilters } from '../../types';
|
import type { FeatureFilters } from '../../types';
|
||||||
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
||||||
import { MODE_LABELS, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
import { MODE_LABELS, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||||
|
|
@ -167,6 +168,7 @@ export default memo(function Map({
|
||||||
const {
|
const {
|
||||||
layers,
|
layers,
|
||||||
popupInfo,
|
popupInfo,
|
||||||
|
clearPopupInfo,
|
||||||
hoverPosition,
|
hoverPosition,
|
||||||
countRange,
|
countRange,
|
||||||
postcodeCountRange,
|
postcodeCountRange,
|
||||||
|
|
@ -309,7 +311,7 @@ export default memo(function Map({
|
||||||
))}
|
))}
|
||||||
{popupInfo && (
|
{popupInfo && (
|
||||||
<div
|
<div
|
||||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white pointer-events-none"
|
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
|
||||||
style={{
|
style={{
|
||||||
left: popupInfo.x,
|
left: popupInfo.x,
|
||||||
top: popupInfo.y - 50,
|
top: popupInfo.y - 50,
|
||||||
|
|
@ -317,6 +319,12 @@ export default memo(function Map({
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
className="absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
|
||||||
|
onClick={clearPopupInfo}
|
||||||
|
>
|
||||||
|
<CloseIcon className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
{popupInfo.isCluster ? (
|
{popupInfo.isCluster ? (
|
||||||
<div className="px-3 py-2 text-center">
|
<div className="px-3 py-2 text-center">
|
||||||
<div className="text-lg font-bold text-teal-600 dark:text-teal-400">
|
<div className="text-lg font-bold text-teal-600 dark:text-teal-400">
|
||||||
|
|
|
||||||
|
|
@ -78,15 +78,11 @@ export function TravelTimeCard({
|
||||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||||
Travel Time ({MODE_LABELS[mode]})
|
Travel Time ({MODE_LABELS[mode]})
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
onClick={() => setShowInfo(true)}
|
|
||||||
className="p-1 -m-0.5 rounded text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 hover:bg-warm-100 dark:hover:bg-warm-700 shrink-0"
|
|
||||||
title="Feature info"
|
|
||||||
>
|
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
|
<IconButton onClick={() => setShowInfo(true)} title="Feature info">
|
||||||
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
|
</IconButton>
|
||||||
{slug && (
|
{slug && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={onTogglePin}
|
onClick={onTogglePin}
|
||||||
|
|
|
||||||
|
|
@ -144,22 +144,13 @@ export default function Header({
|
||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<a
|
||||||
<a
|
href={PAGE_PATHS.invites}
|
||||||
href={PAGE_PATHS.saved}
|
className={tabClass('invites')}
|
||||||
className={tabClass('saved')}
|
onClick={(e) => navLink('invites', e)}
|
||||||
onClick={(e) => navLink('saved', e)}
|
>
|
||||||
>
|
Invite Friends
|
||||||
Saved
|
</a>
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={PAGE_PATHS.invites}
|
|
||||||
className={tabClass('invites')}
|
|
||||||
onClick={(e) => navLink('invites', e)}
|
|
||||||
>
|
|
||||||
Invite Friends
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href={PAGE_PATHS.learn}
|
href={PAGE_PATHS.learn}
|
||||||
|
|
@ -177,6 +168,15 @@ export default function Header({
|
||||||
Pricing
|
Pricing
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{user && (
|
||||||
|
<a
|
||||||
|
href={PAGE_PATHS.saved}
|
||||||
|
className={tabClass('saved')}
|
||||||
|
onClick={(e) => navLink('saved', e)}
|
||||||
|
>
|
||||||
|
Saved
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -88,9 +88,9 @@ export default function MobileMenu({
|
||||||
{user?.subscription !== 'licensed' &&
|
{user?.subscription !== 'licensed' &&
|
||||||
!user?.isAdmin &&
|
!user?.isAdmin &&
|
||||||
mobileNavItem('pricing', 'Pricing')}
|
mobileNavItem('pricing', 'Pricing')}
|
||||||
{user && mobileNavItem('saved', 'Saved')}
|
|
||||||
{user && mobileNavItem('invites', 'Invite Friends')}
|
{user && mobileNavItem('invites', 'Invite Friends')}
|
||||||
{user && mobileNavItem('account', 'Account')}
|
{user && mobileNavItem('account', 'Account')}
|
||||||
|
{user && mobileNavItem('saved', 'Saved')}
|
||||||
|
|
||||||
{/* Dashboard actions */}
|
{/* Dashboard actions */}
|
||||||
{activePage === 'dashboard' && (
|
{activePage === 'dashboard' && (
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
|
||||||
|
|
||||||
export default function VerificationBanner({
|
|
||||||
email,
|
|
||||||
onRequestVerification,
|
|
||||||
onDismiss,
|
|
||||||
}: {
|
|
||||||
email: string;
|
|
||||||
onRequestVerification: (email: string) => Promise<void>;
|
|
||||||
onDismiss: () => void;
|
|
||||||
}) {
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [sent, setSent] = useState(false);
|
|
||||||
|
|
||||||
const handleResend = useCallback(async () => {
|
|
||||||
setSending(true);
|
|
||||||
try {
|
|
||||||
await onRequestVerification(email);
|
|
||||||
setSent(true);
|
|
||||||
setTimeout(() => setSent(false), 3000);
|
|
||||||
} catch {
|
|
||||||
// Error handled by hook
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
}, [email, onRequestVerification]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-2.5 flex items-center justify-between gap-3">
|
|
||||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
|
||||||
Please verify your email address. Check your inbox.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={handleResend}
|
|
||||||
disabled={sending || sent}
|
|
||||||
className="text-sm font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100 disabled:opacity-50 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{sending && <SpinnerIcon className="w-3.5 h-3.5 animate-spin" />}
|
|
||||||
{sent ? 'Sent!' : 'Resend'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onDismiss}
|
|
||||||
className="text-amber-400 dark:text-amber-600 hover:text-amber-600 dark:hover:text-amber-400 text-lg leading-none"
|
|
||||||
aria-label="Dismiss"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -19,7 +19,7 @@ export interface AiFiltersResult {
|
||||||
summary: string;
|
summary: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AiFilterErrorType = 'auth' | 'verification' | 'limit' | 'error';
|
export type AiFilterErrorType = 'auth' | 'limit' | 'error';
|
||||||
|
|
||||||
/** Context of currently active filters, sent for conversational refinement. */
|
/** Context of currently active filters, sent for conversational refinement. */
|
||||||
export interface AiFiltersContext {
|
export interface AiFiltersContext {
|
||||||
|
|
@ -102,9 +102,6 @@ export function useAiFilters(): UseAiFiltersResult {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
setErrorType('auth');
|
setErrorType('auth');
|
||||||
setError(text || 'Login required');
|
setError(text || 'Login required');
|
||||||
} else if (response.status === 403) {
|
|
||||||
setErrorType('verification');
|
|
||||||
setError(text || 'Email verification required');
|
|
||||||
} else if (response.status === 429) {
|
} else if (response.status === 429) {
|
||||||
setErrorType('limit');
|
setErrorType('limit');
|
||||||
setError(text || 'Weekly usage limit reached');
|
setError(text || 'Weekly usage limit reached');
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { trackEvent } from '../lib/analytics';
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
verified: boolean;
|
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
subscription: string;
|
subscription: string;
|
||||||
newsletter: boolean;
|
newsletter: boolean;
|
||||||
|
|
@ -18,7 +17,6 @@ function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
email: record.email,
|
email: record.email,
|
||||||
verified: typeof record.verified === 'boolean' ? record.verified : false,
|
|
||||||
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
|
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
|
||||||
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
|
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
|
||||||
newsletter: typeof record.newsletter === 'boolean' ? record.newsletter : false,
|
newsletter: typeof record.newsletter === 'boolean' ? record.newsletter : false,
|
||||||
|
|
@ -136,20 +134,6 @@ export function useAuth() {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestVerification = useCallback(async (email: string) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await pb.collection('users').requestVerification(email);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Verification request failed';
|
|
||||||
setError(msg);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
const clearError = useCallback(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -163,7 +147,6 @@ export function useAuth() {
|
||||||
loginWithOAuth,
|
loginWithOAuth,
|
||||||
logout,
|
logout,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
requestVerification,
|
|
||||||
refreshAuth,
|
refreshAuth,
|
||||||
clearError,
|
clearError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -771,9 +771,12 @@ export function useDeckLayers({
|
||||||
onHexagonHoverRef.current(null);
|
onHexagonHoverRef.current(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layers,
|
layers,
|
||||||
popupInfo,
|
popupInfo,
|
||||||
|
clearPopupInfo,
|
||||||
hoverPosition,
|
hoverPosition,
|
||||||
countRange,
|
countRange,
|
||||||
postcodeCountRange,
|
postcodeCountRange,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import pb from '../lib/pocketbase';
|
import pb from '../lib/pocketbase';
|
||||||
import { apiUrl, authHeaders } from '../lib/api';
|
import { apiUrl, authHeaders } from '../lib/api';
|
||||||
import { trackEvent } from '../lib/analytics';
|
import { trackEvent } from '../lib/analytics';
|
||||||
|
|
@ -12,39 +12,94 @@ export interface SavedSearch {
|
||||||
created: string;
|
created: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 2000;
|
||||||
|
const MAX_POLL_ATTEMPTS = 15;
|
||||||
|
|
||||||
export function useSavedSearches(userId: string | null) {
|
export function useSavedSearches(userId: string | null) {
|
||||||
const [searches, setSearches] = useState<SavedSearch[]>([]);
|
const [searches, setSearches] = useState<SavedSearch[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const pollAttemptsRef = useRef(0);
|
||||||
|
const userIdRef = useRef(userId);
|
||||||
|
userIdRef.current = userId;
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) {
|
||||||
|
clearInterval(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
pollAttemptsRef.current = 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clean up polling on unmount or userId change
|
||||||
|
useEffect(() => stopPolling, [userId, stopPolling]);
|
||||||
|
|
||||||
|
const fetchRecords = useCallback(async (uid: string): Promise<SavedSearch[]> => {
|
||||||
|
const records = await pb.collection('saved_searches').getFullList({
|
||||||
|
sort: '-created',
|
||||||
|
filter: `user = "${uid}"`,
|
||||||
|
});
|
||||||
|
return records.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: (r as Record<string, unknown>).name as string,
|
||||||
|
params: (r as Record<string, unknown>).params as string,
|
||||||
|
screenshotUrl: (r as Record<string, unknown>).screenshot
|
||||||
|
? pb.files.getURL(r, (r as Record<string, unknown>).screenshot as string)
|
||||||
|
: '',
|
||||||
|
notes: ((r as Record<string, unknown>).notes as string) || '',
|
||||||
|
created: r.created,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startPolling = useCallback(() => {
|
||||||
|
if (pollTimerRef.current) return;
|
||||||
|
pollAttemptsRef.current = 0;
|
||||||
|
pollTimerRef.current = setInterval(async () => {
|
||||||
|
const uid = userIdRef.current;
|
||||||
|
if (!uid) {
|
||||||
|
stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pollAttemptsRef.current++;
|
||||||
|
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||||
|
stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const mapped = await fetchRecords(uid);
|
||||||
|
setSearches(mapped);
|
||||||
|
if (!mapped.some((s) => !s.screenshotUrl)) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent — background poll errors don't surface to UI
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}, [stopPolling, fetchRecords]);
|
||||||
|
|
||||||
const fetchSearches = useCallback(async () => {
|
const fetchSearches = useCallback(async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const records = await pb.collection('saved_searches').getFullList({
|
const mapped = await fetchRecords(userId);
|
||||||
sort: '-created',
|
setSearches(mapped);
|
||||||
filter: `user = "${userId}"`,
|
|
||||||
});
|
// Poll for missing screenshots so they appear without a page refresh
|
||||||
setSearches(
|
if (mapped.some((s) => !s.screenshotUrl)) {
|
||||||
records.map((r) => ({
|
startPolling();
|
||||||
id: r.id,
|
} else {
|
||||||
name: (r as Record<string, unknown>).name as string,
|
stopPolling();
|
||||||
params: (r as Record<string, unknown>).params as string,
|
}
|
||||||
screenshotUrl: (r as Record<string, unknown>).screenshot
|
|
||||||
? pb.files.getURL(r, (r as Record<string, unknown>).screenshot as string)
|
|
||||||
: '',
|
|
||||||
notes: ((r as Record<string, unknown>).notes as string) || '',
|
|
||||||
created: r.created,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load searches');
|
setError(err instanceof Error ? err.message : 'Failed to load searches');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [userId]);
|
}, [userId, fetchRecords, startPolling, stopPolling]);
|
||||||
|
|
||||||
const saveSearch = useCallback(
|
const saveSearch = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
|
|
|
||||||
|
|
@ -205,8 +205,12 @@ export function buildPropertySearchUrls({
|
||||||
(listingStatus as string[]).includes('For rent');
|
(listingStatus as string[]).includes('For rent');
|
||||||
let openrent: string | null = null;
|
let openrent: string | null = null;
|
||||||
if (isRent) {
|
if (isRent) {
|
||||||
|
const postcodeNoSpaces = postcode.replace(/\s+/g, '');
|
||||||
|
const orSlug = postcodeNoSpaces.toLowerCase();
|
||||||
const orParams = new URLSearchParams();
|
const orParams = new URLSearchParams();
|
||||||
orParams.set('term', postcode);
|
orParams.set('term', postcodeNoSpaces.toUpperCase());
|
||||||
|
const radiusKm = Math.round((isPostcode ? 0.25 : radiusMiles) * 1.609);
|
||||||
|
orParams.set('area', String(Math.max(1, radiusKm)));
|
||||||
const rentFilter = filters['Asking rent (monthly)'];
|
const rentFilter = filters['Asking rent (monthly)'];
|
||||||
const minRent =
|
const minRent =
|
||||||
Array.isArray(rentFilter) && typeof rentFilter[0] === 'number' ? rentFilter[0] : undefined;
|
Array.isArray(rentFilter) && typeof rentFilter[0] === 'number' ? rentFilter[0] : undefined;
|
||||||
|
|
@ -216,7 +220,7 @@ export function buildPropertySearchUrls({
|
||||||
if (maxRent !== undefined) orParams.set('prices_max', String(Math.round(maxRent)));
|
if (maxRent !== undefined) orParams.set('prices_max', String(Math.round(maxRent)));
|
||||||
if (minBedrooms !== undefined) orParams.set('bedrooms_min', String(Math.floor(minBedrooms)));
|
if (minBedrooms !== undefined) orParams.set('bedrooms_min', String(Math.floor(minBedrooms)));
|
||||||
if (maxBedrooms !== undefined) orParams.set('bedrooms_max', String(Math.ceil(maxBedrooms)));
|
if (maxBedrooms !== undefined) orParams.set('bedrooms_max', String(Math.ceil(maxBedrooms)));
|
||||||
openrent = `https://www.openrent.com/properties-to-rent?${orParams.toString()}`;
|
openrent = `https://www.openrent.co.uk/properties-to-rent/${orSlug}?${orParams.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rightmove, onthemarket, zoopla, openrent };
|
return { rightmove, onthemarket, zoopla, openrent };
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ set -euo pipefail
|
||||||
# --demo only compute Bank + TCR, transit only (quick test)
|
# --demo only compute Bank + TCR, transit only (quick test)
|
||||||
|
|
||||||
# --- Defaults ---
|
# --- Defaults ---
|
||||||
THREADS=8
|
THREADS=16
|
||||||
HEAP=16g
|
HEAP=16g
|
||||||
NETWORK_DIR=property-data/r5-network
|
NETWORK_DIR=property-data/r5-network
|
||||||
OUTPUT_BASE=property-data/travel-times
|
OUTPUT_BASE=property-data/travel-times
|
||||||
|
|
|
||||||
|
|
@ -226,11 +226,22 @@ export async function initialize(appUrl: string): Promise<void> {
|
||||||
await warmPool();
|
await warmPool();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function takeScreenshot(url: string): Promise<Buffer> {
|
export async function takeScreenshot(url: string, authHeader?: string): Promise<Buffer> {
|
||||||
const page = await acquirePage();
|
const page = await acquirePage();
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Inject Authorization header on API requests so the headless browser
|
||||||
|
// is authenticated (required for licensed users outside the free zone).
|
||||||
|
// Page-level routes take precedence over the context-level cache route,
|
||||||
|
// so only /api/ requests are affected — static assets still use the cache.
|
||||||
|
if (authHeader) {
|
||||||
|
await page.route('**/api/**', async (route) => {
|
||||||
|
const headers = { ...route.request().headers(), authorization: authHeader };
|
||||||
|
await route.continue({ headers });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const response = await page.goto(url, {
|
const response = await page.goto(url, {
|
||||||
waitUntil: 'domcontentloaded',
|
waitUntil: 'domcontentloaded',
|
||||||
timeout: NAVIGATION_TIMEOUT,
|
timeout: NAVIGATION_TIMEOUT,
|
||||||
|
|
@ -265,6 +276,11 @@ export async function takeScreenshot(url: string): Promise<Buffer> {
|
||||||
|
|
||||||
return Buffer.from(screenshot);
|
return Buffer.from(screenshot);
|
||||||
} finally {
|
} finally {
|
||||||
|
// Remove page-level auth route before returning page to pool
|
||||||
|
// so the next screenshot doesn't inherit stale credentials
|
||||||
|
if (authHeader) {
|
||||||
|
await page.unrouteAll({ behavior: 'wait' }).catch(() => {});
|
||||||
|
}
|
||||||
await releasePage(page);
|
await releasePage(page);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,12 @@ app.get('/screenshot', async (req, res) => {
|
||||||
const pagePath = typeof req.query.path === 'string' && req.query.path ? req.query.path : '/';
|
const pagePath = typeof req.query.path === 'string' && req.query.path ? req.query.path : '/';
|
||||||
if (pagePath !== '/') qs.set('path', pagePath);
|
if (pagePath !== '/') qs.set('path', pagePath);
|
||||||
|
|
||||||
|
// Include auth status in cache key so authenticated screenshots
|
||||||
|
// (with hexagons outside free zone) are cached separately
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader) qs.set('_auth', '1');
|
||||||
const cacheKey = cache.buildKey(qs);
|
const cacheKey = cache.buildKey(qs);
|
||||||
|
qs.delete('_auth');
|
||||||
qs.delete('path');
|
qs.delete('path');
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
|
|
@ -74,8 +79,8 @@ app.get('/screenshot', async (req, res) => {
|
||||||
qs.set('screenshot', '1');
|
qs.set('screenshot', '1');
|
||||||
const url = `${APP_URL}${pagePath}?${qs}`;
|
const url = `${APP_URL}${pagePath}?${qs}`;
|
||||||
|
|
||||||
console.log(`Taking screenshot: ${url}`);
|
console.log(`Taking screenshot: ${url}${authHeader ? ' (authenticated)' : ''}`);
|
||||||
const jpeg = await takeScreenshot(url);
|
const jpeg = await takeScreenshot(url, authHeader);
|
||||||
|
|
||||||
// Cache it
|
// Cache it
|
||||||
cache.set(cacheKey, jpeg);
|
cache.set(cacheKey, jpeg);
|
||||||
|
|
|
||||||
319
scripts/zoopla_experiment.py
Executable file
319
scripts/zoopla_experiment.py
Executable file
|
|
@ -0,0 +1,319 @@
|
||||||
|
#!/usr/bin/env -S uv run --project ../finder
|
||||||
|
"""Zoopla scraping experiment — Playwright with stealth + network interception.
|
||||||
|
|
||||||
|
Zoopla uses Next.js App Router with React Server Components. The listing data
|
||||||
|
is NOT in __NEXT_DATA__ or the initial HTML — it's fetched client-side after
|
||||||
|
hydration. This means we need a real browser that:
|
||||||
|
1. Passes Cloudflare's bot detection
|
||||||
|
2. Executes JavaScript to trigger the client-side data fetch
|
||||||
|
3. Intercepts the network response OR scrapes the rendered DOM
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run --project finder scripts/zoopla_experiment.py [OUTCODE]
|
||||||
|
"""
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
ZOOPLA_BASE = "https://www.zoopla.co.uk"
|
||||||
|
|
||||||
|
CHANNELS = {
|
||||||
|
"BUY": "for-sale",
|
||||||
|
"RENT": "to-rent",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_playwright_stealth(outcode: str, channel: str = "BUY"):
|
||||||
|
"""Use Playwright with stealth patches to scrape Zoopla.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Launch stealth browser to bypass Cloudflare
|
||||||
|
2. Navigate to search page
|
||||||
|
3. Wait for listings to render (client-side hydration)
|
||||||
|
4. Try two extraction methods:
|
||||||
|
a. Intercept network requests for API data (cleanest)
|
||||||
|
b. Parse the rendered DOM (fallback)
|
||||||
|
"""
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
from playwright_stealth import Stealth
|
||||||
|
|
||||||
|
url_segment = CHANNELS[channel]
|
||||||
|
search_url = f"{ZOOPLA_BASE}/{url_segment}/properties/{outcode.lower()}/"
|
||||||
|
log.info("Target: %s", search_url)
|
||||||
|
|
||||||
|
intercepted_data = []
|
||||||
|
|
||||||
|
def handle_response(response):
|
||||||
|
"""Capture any API responses that look like listing data."""
|
||||||
|
url = response.url
|
||||||
|
# Look for API/data endpoints
|
||||||
|
if any(kw in url for kw in ["/api/", "graphql", "search", "listing", "property"]):
|
||||||
|
try:
|
||||||
|
if "application/json" in (response.headers.get("content-type", "")):
|
||||||
|
body = response.json()
|
||||||
|
intercepted_data.append({"url": url, "data": body})
|
||||||
|
log.info(" [intercepted] %s (%s)", url[:100], type(body).__name__)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# Launch with stealth-friendly args
|
||||||
|
browser = p.chromium.launch(
|
||||||
|
headless=True,
|
||||||
|
args=[
|
||||||
|
"--disable-blink-features=AutomationControlled",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-web-security",
|
||||||
|
"--lang=en-GB",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
context = browser.new_context(
|
||||||
|
locale="en-GB",
|
||||||
|
timezone_id="Europe/London",
|
||||||
|
viewport={"width": 1920, "height": 1080},
|
||||||
|
user_agent=(
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
# Apply stealth patches (Linux platform, Chrome UA)
|
||||||
|
stealth = Stealth(
|
||||||
|
navigator_platform_override="Linux x86_64",
|
||||||
|
navigator_languages_override=("en-GB", "en"),
|
||||||
|
)
|
||||||
|
stealth.apply_stealth_sync(page)
|
||||||
|
|
||||||
|
# Listen for responses to intercept API data
|
||||||
|
page.on("response", handle_response)
|
||||||
|
|
||||||
|
# Navigate
|
||||||
|
log.info("Navigating to %s ...", search_url)
|
||||||
|
try:
|
||||||
|
page.goto(search_url, wait_until="domcontentloaded", timeout=60000)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Navigation failed: %s", e)
|
||||||
|
browser.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for Cloudflare to resolve
|
||||||
|
log.info("Waiting for Cloudflare challenge to resolve ...")
|
||||||
|
for attempt in range(20):
|
||||||
|
content = page.content()
|
||||||
|
title = page.title()
|
||||||
|
if "Just a moment" in content and "challenge" in content.lower():
|
||||||
|
log.info(" Cloudflare challenge still active (%d/20) title=%s", attempt + 1, title)
|
||||||
|
time.sleep(3)
|
||||||
|
else:
|
||||||
|
log.info(" Challenge resolved! title=%s", title)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
log.error("Cloudflare challenge did not resolve")
|
||||||
|
# Dump page content for debugging
|
||||||
|
print("\n=== Cloudflare challenge page ===")
|
||||||
|
print(page.content()[:3000])
|
||||||
|
browser.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for actual content to render
|
||||||
|
log.info("Waiting for listing content to render ...")
|
||||||
|
try:
|
||||||
|
# Try waiting for property cards to appear
|
||||||
|
page.wait_for_selector(
|
||||||
|
'[data-testid="search-result"], [data-testid="regular-listings"], '
|
||||||
|
'.listing-results, .css-kdnlof, [class*="ListingCard"], '
|
||||||
|
'[class*="listing"], [class*="PropertyCard"]',
|
||||||
|
timeout=15000,
|
||||||
|
)
|
||||||
|
log.info("Listing elements found in DOM!")
|
||||||
|
except Exception:
|
||||||
|
log.warning("No listing elements found by selector. Trying to wait for prices...")
|
||||||
|
try:
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.querySelectorAll('a[href*=\"/for-sale/details/\"]').length > 0",
|
||||||
|
timeout=15000,
|
||||||
|
)
|
||||||
|
log.info("Listing links found in DOM!")
|
||||||
|
except Exception:
|
||||||
|
log.warning("No listing links either. Page may still be loading or we're blocked.")
|
||||||
|
|
||||||
|
# Give hydration a moment
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# --- Extraction Method A: Check intercepted network data ---
|
||||||
|
if intercepted_data:
|
||||||
|
print(f"\n=== Intercepted {len(intercepted_data)} API responses ===")
|
||||||
|
for item in intercepted_data:
|
||||||
|
print(f"\nURL: {item['url'][:150]}")
|
||||||
|
data = item["data"]
|
||||||
|
if isinstance(data, dict):
|
||||||
|
print(f"Keys: {list(data.keys())[:15]}")
|
||||||
|
# Look for listings inside
|
||||||
|
for k, v in data.items():
|
||||||
|
if isinstance(v, list) and len(v) > 2 and isinstance(v[0], dict):
|
||||||
|
print(f" {k}: list of {len(v)} items, [0] keys={list(v[0].keys())[:10]}")
|
||||||
|
elif isinstance(data, list) and data:
|
||||||
|
print(f"Array of {len(data)} items")
|
||||||
|
if isinstance(data[0], dict):
|
||||||
|
print(f" [0] keys: {list(data[0].keys())[:15]}")
|
||||||
|
print(json.dumps(data, indent=2, default=str, ensure_ascii=False)[:2000])
|
||||||
|
|
||||||
|
# --- Extraction Method B: Parse rendered DOM ---
|
||||||
|
log.info("Extracting from rendered DOM ...")
|
||||||
|
|
||||||
|
# Get full page content after hydration
|
||||||
|
content = page.content()
|
||||||
|
|
||||||
|
# Find listing URLs
|
||||||
|
listing_urls = re.findall(r'href="(/for-sale/details/\d+/[^"]*)"', content)
|
||||||
|
log.info("Found %d listing detail links", len(listing_urls))
|
||||||
|
|
||||||
|
# Find prices
|
||||||
|
prices = re.findall(r'£([\d,]+)', content)
|
||||||
|
log.info("Found %d price strings", len(prices))
|
||||||
|
if prices:
|
||||||
|
log.info("Prices: %s", prices[:10])
|
||||||
|
|
||||||
|
# Try to extract structured listing data from the page
|
||||||
|
listings = page.evaluate("""() => {
|
||||||
|
// Try to find listing cards via various selectors
|
||||||
|
const selectors = [
|
||||||
|
'[data-testid="search-result"]',
|
||||||
|
'[data-testid="regular-listings"] > div',
|
||||||
|
'a[href*="/for-sale/details/"]',
|
||||||
|
'[class*="ListingCard"]',
|
||||||
|
'[class*="listing-result"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const elements = document.querySelectorAll(sel);
|
||||||
|
if (elements.length > 2) {
|
||||||
|
return {
|
||||||
|
selector: sel,
|
||||||
|
count: elements.length,
|
||||||
|
// Get text and href from first 3
|
||||||
|
samples: Array.from(elements).slice(0, 3).map(el => ({
|
||||||
|
text: el.innerText?.substring(0, 300),
|
||||||
|
href: el.href || el.querySelector('a')?.href || '',
|
||||||
|
html: el.outerHTML?.substring(0, 500),
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: find all links to listing detail pages
|
||||||
|
const links = Array.from(document.querySelectorAll('a[href*="/details/"]'));
|
||||||
|
if (links.length > 0) {
|
||||||
|
return {
|
||||||
|
selector: 'a[href*="/details/"]',
|
||||||
|
count: links.length,
|
||||||
|
samples: links.slice(0, 5).map(el => ({
|
||||||
|
text: el.innerText?.substring(0, 300),
|
||||||
|
href: el.href,
|
||||||
|
parentText: el.closest('div, li, article')?.innerText?.substring(0, 500) || '',
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: get page structure
|
||||||
|
return {
|
||||||
|
selector: 'none',
|
||||||
|
count: 0,
|
||||||
|
bodyText: document.body?.innerText?.substring(0, 2000),
|
||||||
|
title: document.title,
|
||||||
|
};
|
||||||
|
}""")
|
||||||
|
|
||||||
|
print(f"\n=== DOM Extraction Results ===")
|
||||||
|
print(json.dumps(listings, indent=2, ensure_ascii=False)[:5000])
|
||||||
|
|
||||||
|
# Also extract cookies for potential reuse
|
||||||
|
cookies = context.cookies()
|
||||||
|
zoopla_cookies = {c["name"]: c["value"] for c in cookies if ".zoopla.co.uk" in c.get("domain", "")}
|
||||||
|
ua = page.evaluate("navigator.userAgent")
|
||||||
|
|
||||||
|
print(f"\n=== Session Info ===")
|
||||||
|
print(f"Cookies ({len(zoopla_cookies)}): {list(zoopla_cookies.keys())}")
|
||||||
|
print(f"User-Agent: {ua}")
|
||||||
|
|
||||||
|
if zoopla_cookies:
|
||||||
|
# Save cookies for reuse
|
||||||
|
print(f"\n=== Reusable cookie env vars ===")
|
||||||
|
for name, value in zoopla_cookies.items():
|
||||||
|
print(f" {name}={value[:50]}...")
|
||||||
|
|
||||||
|
# --- Try a detail page if we found any listing URLs ---
|
||||||
|
if listing_urls:
|
||||||
|
detail_path = listing_urls[0]
|
||||||
|
detail_url = f"{ZOOPLA_BASE}{detail_path}"
|
||||||
|
log.info("--- Fetching detail page: %s ---", detail_url)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
page.goto(detail_url, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
time.sleep(5) # Let it hydrate
|
||||||
|
|
||||||
|
detail = page.evaluate("""() => {
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
// Price
|
||||||
|
const priceEl = document.querySelector('[data-testid="price"]')
|
||||||
|
|| document.querySelector('[class*="price"]');
|
||||||
|
result.price = priceEl?.innerText || '';
|
||||||
|
|
||||||
|
// Address
|
||||||
|
const addrEl = document.querySelector('[data-testid="address-label"]')
|
||||||
|
|| document.querySelector('h1') || document.querySelector('address');
|
||||||
|
result.address = addrEl?.innerText || '';
|
||||||
|
|
||||||
|
// Key features
|
||||||
|
const features = Array.from(document.querySelectorAll('[data-testid="listing_feature"] li, [class*="feature"] li'));
|
||||||
|
result.features = features.map(f => f.innerText).slice(0, 15);
|
||||||
|
|
||||||
|
// Bedrooms/bathrooms from icons or text
|
||||||
|
const specs = document.querySelectorAll('[data-testid="beds-label"], [data-testid="baths-label"], [class*="bed"], [class*="bath"]');
|
||||||
|
result.specs = Array.from(specs).map(s => s.innerText).slice(0, 5);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
const desc = document.querySelector('[data-testid="listing_description"], [class*="description"]');
|
||||||
|
result.description = desc?.innerText?.substring(0, 500) || '';
|
||||||
|
|
||||||
|
// Agent
|
||||||
|
const agent = document.querySelector('[data-testid="agent-details"], [class*="agent"]');
|
||||||
|
result.agent = agent?.innerText?.substring(0, 200) || '';
|
||||||
|
|
||||||
|
// Full page text summary
|
||||||
|
result.pageTitle = document.title;
|
||||||
|
result.bodyPreview = document.body?.innerText?.substring(0, 1000);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}""")
|
||||||
|
|
||||||
|
print(f"\n=== Detail Page Data ===")
|
||||||
|
print(json.dumps(detail, indent=2, ensure_ascii=False)[:3000])
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
outcode = sys.argv[1] if len(sys.argv) > 1 else "E1"
|
||||||
|
channel = "BUY"
|
||||||
|
log.info("=== Zoopla Scraping Experiment (Playwright Stealth) ===")
|
||||||
|
log.info("Outcode: %s, Channel: %s", outcode, channel)
|
||||||
|
run_playwright_stealth(outcode, channel)
|
||||||
|
log.info("=== Done ===")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -979,3 +979,214 @@
|
||||||
2026-03-15T21:20:43.239746Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
2026-03-15T21:20:43.239746Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
2026-03-15T21:20:47.481371Z INFO property_map_server: All memory pages locked (mlockall)
|
2026-03-15T21:20:47.481371Z INFO property_map_server: All memory pages locked (mlockall)
|
||||||
2026-03-15T21:20:47.481419Z INFO property_map_server: Server listening on 0.0.0.0:8001
|
2026-03-15T21:20:47.481419Z INFO property_map_server: Server listening on 0.0.0.0:8001
|
||||||
|
2026-03-15T21:21:42.708428Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:22:42.708082Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:23:42.708266Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:24:42.718391Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:25:41.332330Z INFO property_map_server: Prometheus metrics initialized
|
||||||
|
2026-03-15T21:25:41.332500Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
|
||||||
|
2026-03-15T21:25:41.332508Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
|
||||||
|
2026-03-15T21:25:41.432307Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
|
||||||
|
2026-03-15T21:25:41.432318Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
|
||||||
|
2026-03-15T21:25:44.304936Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
|
||||||
|
2026-03-15T21:25:44.304945Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
|
||||||
|
2026-03-15T21:25:44.611586Z INFO property_map_server::data::property: buy listings joined rows=474965
|
||||||
|
2026-03-15T21:25:44.613334Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
|
||||||
|
2026-03-15T21:25:44.675390Z INFO property_map_server::data::property: rent listings joined rows=24345
|
||||||
|
2026-03-15T21:25:44.675399Z INFO property_map_server::data::property: Concatenating all data sources
|
||||||
|
2026-03-15T21:26:00.295681Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
|
||||||
|
2026-03-15T21:26:00.312456Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
|
||||||
|
2026-03-15T21:26:01.704637Z INFO property_map_server::data::property: Combined data selected rows=15702691
|
||||||
|
2026-03-15T21:26:01.872395Z INFO property_map_server::data::property: Extracting numeric feature columns
|
||||||
|
2026-03-15T21:26:02.248689Z INFO property_map_server::data::property: Computing histograms for numeric features
|
||||||
|
2026-03-15T21:26:03.562896Z INFO property_map_server::data::property: Extracting string columns
|
||||||
|
2026-03-15T21:26:05.837707Z INFO property_map_server::data::property: Building enum features
|
||||||
|
2026-03-15T21:26:07.241019Z INFO property_map_server::data::property: Extracting renovation history
|
||||||
|
2026-03-15T21:26:09.414029Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
|
||||||
|
2026-03-15T21:26:09.414038Z INFO property_map_server::data::property: Extracting listing features
|
||||||
|
2026-03-15T21:26:09.970429Z INFO property_map_server::data::property: Listing features extracted properties_with_features=412749
|
||||||
|
2026-03-15T21:26:09.970438Z INFO property_map_server::data::property: Sorting rows by spatial locality
|
||||||
|
2026-03-15T21:26:10.866902Z INFO property_map_server::data::property: Building interned strings
|
||||||
|
2026-03-15T21:26:17.086286Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
|
||||||
|
2026-03-15T21:26:19.719576Z INFO property_map_server::data::property: Data loading complete
|
||||||
|
2026-03-15T21:26:21.461788Z INFO property_map_server: Property data loaded rows=15702691 features=68 enums=13
|
||||||
|
2026-03-15T21:26:21.461797Z INFO property_map_server: Building spatial grid index (0.01° cells)
|
||||||
|
2026-03-15T21:26:21.564319Z INFO property_map_server: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-15T21:26:21.564329Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-15T21:26:21.994793Z INFO property_map_server::data::property: H3 precomputation complete (15702691 cells)
|
||||||
|
2026-03-15T21:26:21.994838Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
|
||||||
|
2026-03-15T21:26:21.994845Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
|
||||||
|
2026-03-15T21:26:22.018732Z INFO property_map_server::data::poi: Loaded 678242 POIs
|
||||||
|
2026-03-15T21:26:22.133349Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
|
||||||
|
2026-03-15T21:26:22.133928Z INFO property_map_server::data::poi: POI data loading complete.
|
||||||
|
2026-03-15T21:26:22.171652Z INFO property_map_server: POI data loaded pois=678242
|
||||||
|
2026-03-15T21:26:22.171659Z INFO property_map_server: Building POI spatial grid index
|
||||||
|
2026-03-15T21:26:22.177174Z INFO property_map_server: Loading place data from /app/data/places.parquet
|
||||||
|
2026-03-15T21:26:22.177185Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
|
||||||
|
2026-03-15T21:26:22.178952Z INFO property_map_server::data::places: Loaded 3474 places
|
||||||
|
2026-03-15T21:26:22.179710Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
|
||||||
|
2026-03-15T21:26:22.179774Z INFO property_map_server: Place data loaded places=3474
|
||||||
|
2026-03-15T21:26:22.179781Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
|
||||||
|
2026-03-15T21:26:22.179785Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
|
||||||
|
2026-03-15T21:26:22.183476Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
|
||||||
|
2026-03-15T21:26:30.728685Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
|
||||||
|
2026-03-15T21:26:30.967914Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
|
||||||
|
2026-03-15T21:26:30.967934Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
|
||||||
|
2026-03-15T21:26:31.095450Z INFO property_map_server: PMTiles loaded successfully
|
||||||
|
2026-03-15T21:26:31.145691Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
|
||||||
|
2026-03-15T21:26:31.291690Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
|
||||||
|
2026-03-15T21:26:31.292434Z INFO property_map_server: Precomputed features response groups=8
|
||||||
|
2026-03-15T21:26:31.292449Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
|
||||||
|
2026-03-15T21:26:31.424001Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
|
||||||
|
2026-03-15T21:26:31.435471Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
|
||||||
|
2026-03-15T21:26:31.438556Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
|
||||||
|
2026-03-15T21:26:31.487488Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
|
||||||
|
2026-03-15T21:26:31.492080Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
|
||||||
|
2026-03-15T21:26:31.492166Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
|
||||||
|
2026-03-15T21:26:31.492182Z INFO property_map_server: Loading travel time data from /app/data/travel-times
|
||||||
|
2026-03-15T21:26:31.552671Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
|
||||||
|
2026-03-15T21:26:31.566751Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
|
||||||
|
2026-03-15T21:26:31.583207Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
|
||||||
|
2026-03-15T21:26:31.611276Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1849
|
||||||
|
2026-03-15T21:26:31.611304Z INFO property_map_server: Travel time store loaded modes=4
|
||||||
|
2026-03-15T21:26:31.611349Z INFO property_map_server: Precomputed AI filters system prompt
|
||||||
|
2026-03-15T21:26:32.183021Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:26:36.065617Z INFO property_map_server: All memory pages locked (mlockall)
|
||||||
|
2026-03-15T21:26:36.065653Z INFO property_map_server: Server listening on 0.0.0.0:8001
|
||||||
|
2026-03-15T21:26:39.187390Z INFO property_map_server::routes::features: GET /api/features
|
||||||
|
2026-03-15T21:26:39.192134Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
|
||||||
|
2026-03-15T21:26:39.203820Z INFO property_map_server::routes::features: GET /api/features
|
||||||
|
2026-03-15T21:26:39.206632Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
|
||||||
|
2026-03-15T21:26:39.219137Z INFO property_map_server::routes::features: GET /api/features
|
||||||
|
2026-03-15T21:26:39.219143Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
|
||||||
|
2026-03-15T21:26:39.233780Z INFO property_map_server::routes::features: GET /api/features
|
||||||
|
2026-03-15T21:26:39.234321Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
|
||||||
|
2026-03-15T21:26:40.962559Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
|
||||||
|
2026-03-15T21:26:40.965073Z INFO property_map_server::routes::features: GET /api/features
|
||||||
|
2026-03-15T21:26:41.229268Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=5 rows=14962301 parallel=true cells_before_filter=687 cells_after_filter=687 truncated=false bounds=46.0000,-12.0000,56.5000,12.0000 filters=0 filters_raw="-" travel_entries=0 agg_ms=204.4 total_ms=226.3
|
||||||
|
2026-03-15T21:27:31.658405Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:28:08.681718Z INFO property_map_server: Prometheus metrics initialized
|
||||||
|
2026-03-15T21:28:08.681878Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
|
||||||
|
2026-03-15T21:28:08.681883Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
|
||||||
|
2026-03-15T21:28:08.749237Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
|
||||||
|
2026-03-15T21:28:08.749247Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
|
||||||
|
2026-03-15T21:28:11.033956Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
|
||||||
|
2026-03-15T21:28:11.033967Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
|
||||||
|
2026-03-15T21:28:11.350626Z INFO property_map_server::data::property: buy listings joined rows=474965
|
||||||
|
2026-03-15T21:28:11.352361Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
|
||||||
|
2026-03-15T21:28:11.408617Z INFO property_map_server::data::property: rent listings joined rows=24345
|
||||||
|
2026-03-15T21:28:11.408629Z INFO property_map_server::data::property: Concatenating all data sources
|
||||||
|
2026-03-15T21:28:34.809978Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=474965 rent_listings=24345 total=15702691
|
||||||
|
2026-03-15T21:28:34.810067Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
|
||||||
|
2026-03-15T21:28:36.212393Z INFO property_map_server::data::property: Combined data selected rows=15702691
|
||||||
|
2026-03-15T21:28:36.357174Z INFO property_map_server::data::property: Extracting numeric feature columns
|
||||||
|
2026-03-15T21:28:36.708197Z INFO property_map_server::data::property: Computing histograms for numeric features
|
||||||
|
2026-03-15T21:28:37.750679Z INFO property_map_server::data::property: Extracting string columns
|
||||||
|
2026-03-15T21:28:39.971444Z INFO property_map_server::data::property: Building enum features
|
||||||
|
2026-03-15T21:28:41.375658Z INFO property_map_server::data::property: Extracting renovation history
|
||||||
|
2026-03-15T21:28:43.534332Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
|
||||||
|
2026-03-15T21:28:43.534341Z INFO property_map_server::data::property: Extracting listing features
|
||||||
|
2026-03-15T21:28:44.063198Z INFO property_map_server::data::property: Listing features extracted properties_with_features=412749
|
||||||
|
2026-03-15T21:28:44.063207Z INFO property_map_server::data::property: Sorting rows by spatial locality
|
||||||
|
2026-03-15T21:28:44.984019Z INFO property_map_server::data::property: Building interned strings
|
||||||
|
2026-03-15T21:28:50.851336Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
|
||||||
|
2026-03-15T21:28:53.262932Z INFO property_map_server::data::property: Data loading complete
|
||||||
|
2026-03-15T21:28:54.905369Z INFO property_map_server: Property data loaded rows=15702691 features=68 enums=13
|
||||||
|
2026-03-15T21:28:54.905380Z INFO property_map_server: Building spatial grid index (0.01° cells)
|
||||||
|
2026-03-15T21:28:55.002205Z INFO property_map_server: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-15T21:28:55.002215Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-15T21:28:55.397111Z INFO property_map_server::data::property: H3 precomputation complete (15702691 cells)
|
||||||
|
2026-03-15T21:28:55.397137Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
|
||||||
|
2026-03-15T21:28:55.397153Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
|
||||||
|
2026-03-15T21:28:55.419145Z INFO property_map_server::data::poi: Loaded 678242 POIs
|
||||||
|
2026-03-15T21:28:55.531144Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
|
||||||
|
2026-03-15T21:28:55.531706Z INFO property_map_server::data::poi: POI data loading complete.
|
||||||
|
2026-03-15T21:28:55.567985Z INFO property_map_server: POI data loaded pois=678242
|
||||||
|
2026-03-15T21:28:55.567992Z INFO property_map_server: Building POI spatial grid index
|
||||||
|
2026-03-15T21:28:55.573226Z INFO property_map_server: Loading place data from /app/data/places.parquet
|
||||||
|
2026-03-15T21:28:55.573233Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
|
||||||
|
2026-03-15T21:28:55.573743Z INFO property_map_server::data::places: Loaded 3474 places
|
||||||
|
2026-03-15T21:28:55.574459Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
|
||||||
|
2026-03-15T21:28:55.574532Z INFO property_map_server: Place data loaded places=3474
|
||||||
|
2026-03-15T21:28:55.574542Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
|
||||||
|
2026-03-15T21:28:55.574549Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
|
||||||
|
2026-03-15T21:28:55.578317Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
|
||||||
|
2026-03-15T21:29:03.183680Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
|
||||||
|
2026-03-15T21:29:03.405251Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
|
||||||
|
2026-03-15T21:29:03.405268Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
|
||||||
|
2026-03-15T21:29:03.434100Z INFO property_map_server: PMTiles loaded successfully
|
||||||
|
2026-03-15T21:29:03.477701Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
|
||||||
|
2026-03-15T21:29:03.554483Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
|
||||||
|
2026-03-15T21:29:03.554881Z INFO property_map_server: Precomputed features response groups=8
|
||||||
|
2026-03-15T21:29:03.554897Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
|
||||||
|
2026-03-15T21:29:03.617256Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
|
||||||
|
2026-03-15T21:29:03.619944Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
|
||||||
|
2026-03-15T21:29:03.623749Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
|
||||||
|
2026-03-15T21:29:03.675548Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
|
||||||
|
2026-03-15T21:29:03.684151Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
|
||||||
|
2026-03-15T21:29:03.684193Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
|
||||||
|
2026-03-15T21:29:03.684209Z INFO property_map_server: Loading travel time data from /app/data/travel-times
|
||||||
|
2026-03-15T21:29:03.710851Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
|
||||||
|
2026-03-15T21:29:03.711686Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
|
||||||
|
2026-03-15T21:29:03.712508Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
|
||||||
|
2026-03-15T21:29:03.719832Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1849
|
||||||
|
2026-03-15T21:29:03.719857Z INFO property_map_server: Travel time store loaded modes=4
|
||||||
|
2026-03-15T21:29:03.719915Z INFO property_map_server: Precomputed AI filters system prompt
|
||||||
|
2026-03-15T21:29:04.283936Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:29:06.207339Z INFO property_map_server: All memory pages locked (mlockall)
|
||||||
|
2026-03-15T21:29:06.207379Z INFO property_map_server: Server listening on 0.0.0.0:8001
|
||||||
|
2026-03-15T21:30:03.765875Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:31:03.766908Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:32:03.766043Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:33:03.766227Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:34:03.765838Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:35:03.767373Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:36:03.766378Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:37:03.767873Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:38:03.766260Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:39:03.766874Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:40:03.766081Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:41:03.766181Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:42:03.764629Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:43:03.766341Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:44:03.765359Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:45:03.765298Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:46:03.766437Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:47:03.765492Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:48:03.765087Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:49:03.770788Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:50:03.777608Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:51:03.766162Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:52:03.765925Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:53:03.765007Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:54:03.765681Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:55:03.764567Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:56:03.770996Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:57:03.780547Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:58:03.765696Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T21:59:03.766040Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:00:03.768073Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:01:03.766610Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:02:03.766381Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:03:03.772124Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:04:03.765925Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:05:03.766612Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:06:03.766443Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:07:03.765680Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:08:03.766107Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:09:03.771436Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:10:03.766431Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:11:03.765836Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:12:03.765036Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:13:03.765945Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:14:03.766561Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:15:03.766491Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:16:03.765286Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:17:03.766117Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:18:03.765055Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:19:03.765059Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:20:03.763936Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:21:03.776040Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:22:03.764699Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:23:03.766017Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-15T22:24:03.765408Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
|
|
||||||
335
server-rs/logs/server.log.2026-03-17
Normal file
335
server-rs/logs/server.log.2026-03-17
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
2026-03-17T07:30:51.418735Z INFO property_map_server: Prometheus metrics initialized
|
||||||
|
2026-03-17T07:30:51.418950Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
|
||||||
|
2026-03-17T07:30:51.418957Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
|
||||||
|
2026-03-17T07:30:51.591217Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
|
||||||
|
2026-03-17T07:30:51.591228Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
|
||||||
|
2026-03-17T07:31:03.482386Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
|
||||||
|
2026-03-17T07:31:03.482398Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
|
||||||
|
2026-03-17T07:31:06.206982Z INFO property_map_server::data::property: buy listings joined rows=457076
|
||||||
|
2026-03-17T07:31:06.207003Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
|
||||||
|
2026-03-17T07:31:08.031097Z INFO property_map_server::data::property: rent listings joined rows=122594
|
||||||
|
2026-03-17T07:31:08.031106Z INFO property_map_server::data::property: Concatenating all data sources
|
||||||
|
2026-03-17T07:32:00.170695Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
|
||||||
|
2026-03-17T07:32:00.170797Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
|
||||||
|
2026-03-17T07:32:01.527808Z INFO property_map_server::data::property: Combined data selected rows=15783051
|
||||||
|
2026-03-17T07:32:01.738022Z INFO property_map_server::data::property: Extracting numeric feature columns
|
||||||
|
2026-03-17T07:32:02.164093Z INFO property_map_server::data::property: Computing histograms for numeric features
|
||||||
|
2026-03-17T07:32:03.346133Z INFO property_map_server::data::property: Extracting string columns
|
||||||
|
2026-03-17T07:32:05.803712Z INFO property_map_server::data::property: Building enum features
|
||||||
|
2026-03-17T07:32:07.359340Z INFO property_map_server::data::property: Extracting renovation history
|
||||||
|
2026-03-17T07:32:09.567602Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
|
||||||
|
2026-03-17T07:32:09.567612Z INFO property_map_server::data::property: Extracting listing features
|
||||||
|
2026-03-17T07:32:10.194293Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
|
||||||
|
2026-03-17T07:32:10.194304Z INFO property_map_server::data::property: Sorting rows by spatial locality
|
||||||
|
2026-03-17T07:32:11.130691Z INFO property_map_server::data::property: Building interned strings
|
||||||
|
2026-03-17T07:32:17.391642Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
|
||||||
|
2026-03-17T07:32:20.030170Z INFO property_map_server::data::property: Data loading complete
|
||||||
|
2026-03-17T07:32:21.686179Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
|
||||||
|
2026-03-17T07:32:21.686189Z INFO property_map_server: Building spatial grid index (0.01° cells)
|
||||||
|
2026-03-17T07:32:22.119885Z INFO property_map_server: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-17T07:32:22.119896Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-17T07:32:22.577256Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
|
||||||
|
2026-03-17T07:32:22.577783Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
|
||||||
|
2026-03-17T07:32:22.577790Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
|
||||||
|
2026-03-17T07:32:22.606628Z INFO property_map_server::data::poi: Loaded 678242 POIs
|
||||||
|
2026-03-17T07:32:22.723396Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
|
||||||
|
2026-03-17T07:32:22.724011Z INFO property_map_server::data::poi: POI data loading complete.
|
||||||
|
2026-03-17T07:32:22.763121Z INFO property_map_server: POI data loaded pois=678242
|
||||||
|
2026-03-17T07:32:22.763130Z INFO property_map_server: Building POI spatial grid index
|
||||||
|
2026-03-17T07:32:22.768959Z INFO property_map_server: Loading place data from /app/data/places.parquet
|
||||||
|
2026-03-17T07:32:22.768968Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
|
||||||
|
2026-03-17T07:32:22.772858Z INFO property_map_server::data::places: Loaded 3474 places
|
||||||
|
2026-03-17T07:32:22.773855Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
|
||||||
|
2026-03-17T07:32:22.774015Z INFO property_map_server: Place data loaded places=3474
|
||||||
|
2026-03-17T07:32:22.774027Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
|
||||||
|
2026-03-17T07:32:22.774032Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
|
||||||
|
2026-03-17T07:32:22.787541Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
|
||||||
|
2026-03-17T07:32:31.937299Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
|
||||||
|
2026-03-17T07:32:32.173875Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
|
||||||
|
2026-03-17T07:32:32.174039Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
|
||||||
|
2026-03-17T07:32:32.271059Z INFO property_map_server: PMTiles loaded successfully
|
||||||
|
2026-03-17T07:32:32.315679Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
|
||||||
|
2026-03-17T07:32:32.394604Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
|
||||||
|
2026-03-17T07:32:32.394776Z INFO property_map_server: Precomputed features response groups=8
|
||||||
|
2026-03-17T07:32:32.394795Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
|
||||||
|
2026-03-17T07:32:32.593635Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
|
||||||
|
2026-03-17T07:32:32.598562Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
|
||||||
|
2026-03-17T07:32:32.602615Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
|
||||||
|
2026-03-17T07:32:32.700044Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
|
||||||
|
2026-03-17T07:32:32.703401Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
|
||||||
|
2026-03-17T07:32:32.703422Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
|
||||||
|
2026-03-17T07:32:32.703435Z INFO property_map_server: Loading travel time data from /app/data/travel-times
|
||||||
|
2026-03-17T07:32:33.124089Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
|
||||||
|
2026-03-17T07:32:33.129130Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
|
||||||
|
2026-03-17T07:32:33.136319Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
|
||||||
|
2026-03-17T07:32:33.199470Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
|
||||||
|
2026-03-17T07:32:33.199512Z INFO property_map_server: Travel time store loaded modes=4
|
||||||
|
2026-03-17T07:32:33.199568Z INFO property_map_server: Precomputed AI filters system prompt
|
||||||
|
2026-03-17T07:32:33.247029Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:32:41.343709Z INFO property_map_server: All memory pages locked (mlockall)
|
||||||
|
2026-03-17T07:32:41.343741Z INFO property_map_server: Server listening on 0.0.0.0:8001
|
||||||
|
2026-03-17T07:33:33.247983Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:34:33.248115Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:35:33.247077Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:36:33.246775Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:37:33.245462Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:38:33.245965Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:39:33.245978Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:40:33.246783Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:41:33.245498Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:42:33.245587Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:43:33.245907Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:44:33.246696Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:45:33.246006Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T07:46:30.259530Z INFO property_map_server: Prometheus metrics initialized
|
||||||
|
2026-03-17T07:46:30.259726Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
|
||||||
|
2026-03-17T07:46:30.259735Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
|
||||||
|
2026-03-17T07:46:30.325086Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
|
||||||
|
2026-03-17T07:46:30.325097Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
|
||||||
|
2026-03-17T07:46:32.757459Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
|
||||||
|
2026-03-17T07:46:32.757469Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
|
||||||
|
2026-03-17T07:46:33.043727Z INFO property_map_server::data::property: buy listings joined rows=457076
|
||||||
|
2026-03-17T07:46:33.043750Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
|
||||||
|
2026-03-17T07:46:33.139537Z INFO property_map_server::data::property: rent listings joined rows=122594
|
||||||
|
2026-03-17T07:46:33.139545Z INFO property_map_server::data::property: Concatenating all data sources
|
||||||
|
2026-03-17T08:31:50.056528Z INFO property_map_server: Prometheus metrics initialized
|
||||||
|
2026-03-17T08:31:50.056716Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
|
||||||
|
2026-03-17T08:31:50.056723Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
|
||||||
|
2026-03-17T08:31:50.259958Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
|
||||||
|
2026-03-17T08:31:50.259971Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
|
||||||
|
2026-03-17T08:32:02.569149Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
|
||||||
|
2026-03-17T08:32:02.569201Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
|
||||||
|
2026-03-17T08:32:03.699632Z INFO property_map_server::data::property: buy listings joined rows=457076
|
||||||
|
2026-03-17T08:32:03.699651Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
|
||||||
|
2026-03-17T08:32:03.826074Z INFO property_map_server::data::property: rent listings joined rows=122594
|
||||||
|
2026-03-17T08:32:03.826084Z INFO property_map_server::data::property: Concatenating all data sources
|
||||||
|
2026-03-17T08:32:43.785403Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
|
||||||
|
2026-03-17T08:32:43.785499Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
|
||||||
|
2026-03-17T08:32:45.220814Z INFO property_map_server::data::property: Combined data selected rows=15783051
|
||||||
|
2026-03-17T08:32:45.421342Z INFO property_map_server::data::property: Extracting numeric feature columns
|
||||||
|
2026-03-17T08:32:45.834125Z INFO property_map_server::data::property: Computing histograms for numeric features
|
||||||
|
2026-03-17T08:32:47.061266Z INFO property_map_server::data::property: Extracting string columns
|
||||||
|
2026-03-17T08:32:49.344991Z INFO property_map_server::data::property: Building enum features
|
||||||
|
2026-03-17T08:32:50.754854Z INFO property_map_server::data::property: Extracting renovation history
|
||||||
|
2026-03-17T08:32:52.906620Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
|
||||||
|
2026-03-17T08:32:52.906629Z INFO property_map_server::data::property: Extracting listing features
|
||||||
|
2026-03-17T08:32:53.563050Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
|
||||||
|
2026-03-17T08:32:53.563059Z INFO property_map_server::data::property: Sorting rows by spatial locality
|
||||||
|
2026-03-17T08:32:54.502830Z INFO property_map_server::data::property: Building interned strings
|
||||||
|
2026-03-17T08:33:00.593312Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
|
||||||
|
2026-03-17T08:33:03.178312Z INFO property_map_server::data::property: Data loading complete
|
||||||
|
2026-03-17T08:33:04.964374Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
|
||||||
|
2026-03-17T08:33:04.964383Z INFO property_map_server: Building spatial grid index (0.01° cells)
|
||||||
|
2026-03-17T08:33:05.065094Z INFO property_map_server: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-17T08:33:05.065102Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-17T08:33:05.486703Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
|
||||||
|
2026-03-17T08:33:05.486729Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
|
||||||
|
2026-03-17T08:33:05.486734Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
|
||||||
|
2026-03-17T08:33:05.529351Z INFO property_map_server::data::poi: Loaded 678242 POIs
|
||||||
|
2026-03-17T08:33:05.642021Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
|
||||||
|
2026-03-17T08:33:05.642611Z INFO property_map_server::data::poi: POI data loading complete.
|
||||||
|
2026-03-17T08:33:05.681563Z INFO property_map_server: POI data loaded pois=678242
|
||||||
|
2026-03-17T08:33:05.681574Z INFO property_map_server: Building POI spatial grid index
|
||||||
|
2026-03-17T08:33:05.687162Z INFO property_map_server: Loading place data from /app/data/places.parquet
|
||||||
|
2026-03-17T08:33:05.687169Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
|
||||||
|
2026-03-17T08:33:05.705798Z INFO property_map_server::data::places: Loaded 3474 places
|
||||||
|
2026-03-17T08:33:05.706609Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
|
||||||
|
2026-03-17T08:33:05.706675Z INFO property_map_server: Place data loaded places=3474
|
||||||
|
2026-03-17T08:33:05.706689Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
|
||||||
|
2026-03-17T08:33:05.706695Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
|
||||||
|
2026-03-17T08:33:05.780250Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
|
||||||
|
2026-03-17T08:33:14.655514Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
|
||||||
|
2026-03-17T08:33:14.888462Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
|
||||||
|
2026-03-17T08:33:14.888478Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
|
||||||
|
2026-03-17T08:33:15.021983Z INFO property_map_server: PMTiles loaded successfully
|
||||||
|
2026-03-17T08:33:15.065572Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
|
||||||
|
2026-03-17T08:33:15.140720Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
|
||||||
|
2026-03-17T08:33:15.141331Z INFO property_map_server: Precomputed features response groups=8
|
||||||
|
2026-03-17T08:33:15.141349Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
|
||||||
|
2026-03-17T08:33:15.246791Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
|
||||||
|
2026-03-17T08:33:15.254863Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
|
||||||
|
2026-03-17T08:33:15.258892Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
|
||||||
|
2026-03-17T08:33:15.329192Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
|
||||||
|
2026-03-17T08:33:15.333036Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
|
||||||
|
2026-03-17T08:33:15.333055Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
|
||||||
|
2026-03-17T08:33:15.333066Z INFO property_map_server: Loading travel time data from /app/data/travel-times
|
||||||
|
2026-03-17T08:33:15.398969Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
|
||||||
|
2026-03-17T08:33:15.403743Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
|
||||||
|
2026-03-17T08:33:15.404640Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
|
||||||
|
2026-03-17T08:33:15.414586Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
|
||||||
|
2026-03-17T08:33:15.414612Z INFO property_map_server: Travel time store loaded modes=4
|
||||||
|
2026-03-17T08:33:15.414666Z INFO property_map_server: Precomputed AI filters system prompt
|
||||||
|
2026-03-17T08:33:16.003045Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:33:19.581012Z INFO property_map_server: All memory pages locked (mlockall)
|
||||||
|
2026-03-17T08:33:19.581049Z INFO property_map_server: Server listening on 0.0.0.0:8001
|
||||||
|
2026-03-17T08:33:22.213990Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
|
||||||
|
2026-03-17T08:33:22.216578Z INFO property_map_server::routes::features: GET /api/features
|
||||||
|
2026-03-17T08:33:22.227193Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
|
||||||
|
2026-03-17T08:33:22.232847Z INFO property_map_server::routes::features: GET /api/features
|
||||||
|
2026-03-17T08:33:22.409378Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=145554 parallel=true cells_before_filter=455 cells_after_filter=297 truncated=false bounds=51.4896,-0.1648,51.5404,-0.0952 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.4 agg_ms=7.5 json_ms=0.8 total_ms=8.7
|
||||||
|
2026-03-17T08:33:22.446379Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=145554 parallel=true cells_before_filter=455 cells_after_filter=297 truncated=false bounds=51.4896,-0.1648,51.5404,-0.0952 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=4.1 json_ms=0.5 total_ms=4.7
|
||||||
|
2026-03-17T08:34:15.461433Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:34:29.710796Z INFO property_map_server::routes::features: GET /api/features
|
||||||
|
2026-03-17T08:34:29.713513Z INFO property_map_server::routes::pois: GET /api/poi-categories count=74 groups=11
|
||||||
|
2026-03-17T08:34:30.274542Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=145554 parallel=true cells_before_filter=455 cells_after_filter=297 truncated=false bounds=51.4896,-0.1648,51.5404,-0.0952 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=2.5 json_ms=0.6 total_ms=3.2
|
||||||
|
2026-03-17T08:34:31.462250Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4987ffff resolution=9 total_count=243 filters=1 filters_raw="Listing status:Historical sale" ms=0.2
|
||||||
|
2026-03-17T08:34:31.674788Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=82474 parallel=true cells_before_filter=296 cells_after_filter=201 truncated=false bounds=51.4896,-0.1524,51.5404,-0.1076 filters=1 filters_raw="Listing status:Historical sale" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=0.9 json_ms=0.5 total_ms=1.5
|
||||||
|
2026-03-17T08:34:32.542179Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4d33ffff resolution=9 total_count=746 filters=1 filters_raw="Listing status:Historical sale" ms=0.5
|
||||||
|
2026-03-17T08:34:34.469487Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4d33ffff resolution=9 total_count=6 filters=1 filters_raw="Listing status:For rent" ms=0.1
|
||||||
|
2026-03-17T08:34:34.620706Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=82474 parallel=true cells_before_filter=274 cells_after_filter=196 truncated=false bounds=51.4896,-0.1524,51.5404,-0.1076 filters=1 filters_raw="Listing status:For rent" fields=0 travel_entries=0 grid_ms=0.1 agg_ms=0.7 json_ms=0.4 total_ms=1.1
|
||||||
|
2026-03-17T08:35:15.464691Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:36:15.461317Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:37:15.462465Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:38:15.461428Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:39:15.463264Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:40:15.466916Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:41:15.463402Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:42:15.462539Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:43:15.461880Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:44:15.462263Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:45:15.461882Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:46:15.462228Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:47:15.462476Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:47:28.935265Z INFO property_map_server: Prometheus metrics initialized
|
||||||
|
2026-03-17T08:47:28.935449Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
|
||||||
|
2026-03-17T08:47:28.935457Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
|
||||||
|
2026-03-17T08:47:29.007775Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
|
||||||
|
2026-03-17T08:47:29.007785Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
|
||||||
|
2026-03-17T08:47:31.674791Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
|
||||||
|
2026-03-17T08:47:31.674802Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
|
||||||
|
2026-03-17T08:47:31.972527Z INFO property_map_server::data::property: buy listings joined rows=457076
|
||||||
|
2026-03-17T08:47:31.972545Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
|
||||||
|
2026-03-17T08:47:32.082470Z INFO property_map_server::data::property: rent listings joined rows=122594
|
||||||
|
2026-03-17T08:47:32.082480Z INFO property_map_server::data::property: Concatenating all data sources
|
||||||
|
2026-03-17T08:47:43.806418Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
|
||||||
|
2026-03-17T08:47:43.806509Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
|
||||||
|
2026-03-17T08:47:45.135285Z INFO property_map_server::data::property: Combined data selected rows=15783051
|
||||||
|
2026-03-17T08:47:45.326377Z INFO property_map_server::data::property: Extracting numeric feature columns
|
||||||
|
2026-03-17T08:47:45.712528Z INFO property_map_server::data::property: Computing histograms for numeric features
|
||||||
|
2026-03-17T08:47:46.876195Z INFO property_map_server::data::property: Extracting string columns
|
||||||
|
2026-03-17T08:47:49.145516Z INFO property_map_server::data::property: Building enum features
|
||||||
|
2026-03-17T08:47:50.661409Z INFO property_map_server::data::property: Extracting renovation history
|
||||||
|
2026-03-17T08:47:52.947453Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
|
||||||
|
2026-03-17T08:47:52.947462Z INFO property_map_server::data::property: Extracting listing features
|
||||||
|
2026-03-17T08:47:53.599162Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
|
||||||
|
2026-03-17T08:47:53.599171Z INFO property_map_server::data::property: Sorting rows by spatial locality
|
||||||
|
2026-03-17T08:47:54.619942Z INFO property_map_server::data::property: Building interned strings
|
||||||
|
2026-03-17T08:48:00.802774Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
|
||||||
|
2026-03-17T08:48:03.547995Z INFO property_map_server::data::property: Data loading complete
|
||||||
|
2026-03-17T08:48:05.049275Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
|
||||||
|
2026-03-17T08:48:05.049293Z INFO property_map_server: Building spatial grid index (0.01° cells)
|
||||||
|
2026-03-17T08:48:05.459943Z INFO property_map_server: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-17T08:48:05.459953Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-17T08:48:05.865563Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
|
||||||
|
2026-03-17T08:48:05.865637Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
|
||||||
|
2026-03-17T08:48:05.865651Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
|
||||||
|
2026-03-17T08:48:05.886166Z INFO property_map_server::data::poi: Loaded 678242 POIs
|
||||||
|
2026-03-17T08:48:06.006159Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
|
||||||
|
2026-03-17T08:48:06.006744Z INFO property_map_server::data::poi: POI data loading complete.
|
||||||
|
2026-03-17T08:48:06.043360Z INFO property_map_server: POI data loaded pois=678242
|
||||||
|
2026-03-17T08:48:06.043368Z INFO property_map_server: Building POI spatial grid index
|
||||||
|
2026-03-17T08:48:06.048757Z INFO property_map_server: Loading place data from /app/data/places.parquet
|
||||||
|
2026-03-17T08:48:06.048766Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
|
||||||
|
2026-03-17T08:48:06.049291Z INFO property_map_server::data::places: Loaded 3474 places
|
||||||
|
2026-03-17T08:48:06.050002Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
|
||||||
|
2026-03-17T08:48:06.050053Z INFO property_map_server: Place data loaded places=3474
|
||||||
|
2026-03-17T08:48:06.050061Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
|
||||||
|
2026-03-17T08:48:06.050064Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
|
||||||
|
2026-03-17T08:48:06.062151Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
|
||||||
|
2026-03-17T08:48:15.297171Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
|
||||||
|
2026-03-17T08:48:15.545357Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
|
||||||
|
2026-03-17T08:48:15.545379Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
|
||||||
|
2026-03-17T08:48:15.640450Z INFO property_map_server: PMTiles loaded successfully
|
||||||
|
2026-03-17T08:48:15.684715Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
|
||||||
|
2026-03-17T08:48:15.789766Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
|
||||||
|
2026-03-17T08:48:15.790261Z INFO property_map_server: Precomputed features response groups=8
|
||||||
|
2026-03-17T08:48:15.790275Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
|
||||||
|
2026-03-17T08:48:15.852396Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
|
||||||
|
2026-03-17T08:48:15.854872Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
|
||||||
|
2026-03-17T08:48:15.858800Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
|
||||||
|
2026-03-17T08:48:15.911308Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
|
||||||
|
2026-03-17T08:48:15.915275Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
|
||||||
|
2026-03-17T08:48:15.915303Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
|
||||||
|
2026-03-17T08:48:15.915316Z INFO property_map_server: Loading travel time data from /app/data/travel-times
|
||||||
|
2026-03-17T08:48:16.153964Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
|
||||||
|
2026-03-17T08:48:16.155556Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
|
||||||
|
2026-03-17T08:48:16.156564Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
|
||||||
|
2026-03-17T08:48:16.168132Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
|
||||||
|
2026-03-17T08:48:16.168166Z INFO property_map_server: Travel time store loaded modes=4
|
||||||
|
2026-03-17T08:48:16.168228Z INFO property_map_server: Precomputed AI filters system prompt
|
||||||
|
2026-03-17T08:48:16.774064Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:48:18.295547Z INFO property_map_server: All memory pages locked (mlockall)
|
||||||
|
2026-03-17T08:48:18.295586Z INFO property_map_server: Server listening on 0.0.0.0:8001
|
||||||
|
2026-03-17T08:49:16.216499Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:50:16.215664Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:51:16.214094Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:52:16.215038Z WARN property_map_server::pocketbase: PocketBase invites count query failed: 400 Bad Request
|
||||||
|
2026-03-17T08:53:00.492875Z INFO property_map_server: Prometheus metrics initialized
|
||||||
|
2026-03-17T08:53:00.493149Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet, /app/data-scraped/online_listings_buy.parquet, /app/data-scraped/online_listings_rent.parquet
|
||||||
|
2026-03-17T08:53:00.493156Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
|
||||||
|
2026-03-17T08:53:00.728565Z INFO property_map_server::data::property: Postcode features loaded rows=1262367
|
||||||
|
2026-03-17T08:53:00.728575Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
|
||||||
|
2026-03-17T08:53:03.595748Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203381
|
||||||
|
2026-03-17T08:53:03.595759Z INFO property_map_server::data::property: Loading buy listings from "/app/data-scraped/online_listings_buy.parquet"
|
||||||
|
2026-03-17T08:53:03.975669Z INFO property_map_server::data::property: buy listings joined rows=457076
|
||||||
|
2026-03-17T08:53:03.975687Z INFO property_map_server::data::property: Loading rent listings from "/app/data-scraped/online_listings_rent.parquet"
|
||||||
|
2026-03-17T08:53:04.083853Z INFO property_map_server::data::property: rent listings joined rows=122594
|
||||||
|
2026-03-17T08:53:04.083863Z INFO property_map_server::data::property: Concatenating all data sources
|
||||||
|
2026-03-17T08:53:19.531799Z INFO property_map_server::data::property: All data sources combined properties=15203381 buy_listings=457076 rent_listings=122594 total=15783051
|
||||||
|
2026-03-17T08:53:19.531893Z INFO property_map_server::data::property: Feature columns from config numeric=55 enums=13 total=68
|
||||||
|
2026-03-17T08:53:20.977401Z INFO property_map_server::data::property: Combined data selected rows=15783051
|
||||||
|
2026-03-17T08:53:21.166389Z INFO property_map_server::data::property: Extracting numeric feature columns
|
||||||
|
2026-03-17T08:53:21.555895Z INFO property_map_server::data::property: Computing histograms for numeric features
|
||||||
|
2026-03-17T08:53:22.777545Z INFO property_map_server::data::property: Extracting string columns
|
||||||
|
2026-03-17T08:53:25.067611Z INFO property_map_server::data::property: Building enum features
|
||||||
|
2026-03-17T08:53:26.433346Z INFO property_map_server::data::property: Extracting renovation history
|
||||||
|
2026-03-17T08:53:28.667594Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1829807
|
||||||
|
2026-03-17T08:53:28.667602Z INFO property_map_server::data::property: Extracting listing features
|
||||||
|
2026-03-17T08:53:29.309247Z INFO property_map_server::data::property: Listing features extracted properties_with_features=518063
|
||||||
|
2026-03-17T08:53:29.309255Z INFO property_map_server::data::property: Sorting rows by spatial locality
|
||||||
|
2026-03-17T08:53:30.205482Z INFO property_map_server::data::property: Building interned strings
|
||||||
|
2026-03-17T08:53:36.247881Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
|
||||||
|
2026-03-17T08:53:38.758705Z INFO property_map_server::data::property: Data loading complete
|
||||||
|
2026-03-17T08:53:40.180446Z INFO property_map_server: Property data loaded rows=15783051 features=68 enums=13
|
||||||
|
2026-03-17T08:53:40.180455Z INFO property_map_server: Building spatial grid index (0.01° cells)
|
||||||
|
2026-03-17T08:53:40.577820Z INFO property_map_server: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-17T08:53:40.577828Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
|
||||||
|
2026-03-17T08:53:40.972135Z INFO property_map_server::data::property: H3 precomputation complete (15783051 cells)
|
||||||
|
2026-03-17T08:53:40.972155Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
|
||||||
|
2026-03-17T08:53:40.972161Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
|
||||||
|
2026-03-17T08:53:41.018292Z INFO property_map_server::data::poi: Loaded 678242 POIs
|
||||||
|
2026-03-17T08:53:41.129204Z INFO property_map_server::data::poi: POI string columns interned category_unique=74 group_unique=11 emoji_unique=71
|
||||||
|
2026-03-17T08:53:41.129769Z INFO property_map_server::data::poi: POI data loading complete.
|
||||||
|
2026-03-17T08:53:41.168005Z INFO property_map_server: POI data loaded pois=678242
|
||||||
|
2026-03-17T08:53:41.168011Z INFO property_map_server: Building POI spatial grid index
|
||||||
|
2026-03-17T08:53:41.173291Z INFO property_map_server: Loading place data from /app/data/places.parquet
|
||||||
|
2026-03-17T08:53:41.173297Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
|
||||||
|
2026-03-17T08:53:41.175229Z INFO property_map_server::data::places: Loaded 3474 places
|
||||||
|
2026-03-17T08:53:41.176075Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
|
||||||
|
2026-03-17T08:53:41.176126Z INFO property_map_server: Place data loaded places=3474
|
||||||
|
2026-03-17T08:53:41.176134Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
|
||||||
|
2026-03-17T08:53:41.176137Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
|
||||||
|
2026-03-17T08:53:41.178186Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
|
||||||
|
2026-03-17T08:53:51.542107Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
|
||||||
|
2026-03-17T08:53:51.769077Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
|
||||||
|
2026-03-17T08:53:51.769098Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
|
||||||
|
2026-03-17T08:53:51.769313Z INFO property_map_server: PMTiles loaded successfully
|
||||||
|
2026-03-17T08:53:51.811454Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
|
||||||
|
2026-03-17T08:53:51.881249Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
|
||||||
|
2026-03-17T08:53:51.881405Z INFO property_map_server: Precomputed features response groups=8
|
||||||
|
2026-03-17T08:53:51.881422Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
|
||||||
|
2026-03-17T08:53:51.933372Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
|
||||||
|
2026-03-17T08:53:51.935544Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
|
||||||
|
2026-03-17T08:53:51.938605Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
|
||||||
|
2026-03-17T08:53:51.988188Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
|
||||||
|
2026-03-17T08:53:51.992737Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
|
||||||
|
2026-03-17T08:53:51.992761Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
|
||||||
|
2026-03-17T08:53:51.992778Z INFO property_map_server: Loading travel time data from /app/data/travel-times
|
||||||
|
2026-03-17T08:53:52.012596Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2780
|
||||||
|
2026-03-17T08:53:52.012912Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=350
|
||||||
|
2026-03-17T08:53:52.013296Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=355
|
||||||
|
2026-03-17T08:53:52.015215Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=1869
|
||||||
|
2026-03-17T08:53:52.015233Z INFO property_map_server: Travel time store loaded modes=4
|
||||||
|
2026-03-17T08:53:52.015276Z INFO property_map_server: Precomputed AI filters system prompt
|
||||||
|
2026-03-17T08:53:54.777281Z INFO property_map_server: All memory pages locked (mlockall)
|
||||||
|
2026-03-17T08:53:54.777322Z INFO property_map_server: Server listening on 0.0.0.0:8001
|
||||||
|
|
@ -18,8 +18,6 @@ pub struct PocketBaseUser {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub verified: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub subscription: String,
|
pub subscription: String,
|
||||||
|
|
|
||||||
|
|
@ -236,19 +236,24 @@ impl TravelTimeStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Slugify a place name to match travel time file naming convention.
|
/// Slugify a place name to match Java `originFilename()` convention.
|
||||||
/// "Abbey Hey" → "abbey-hey", "A'Bhuaile Ghlas" → "a-bhuaile-ghlas"
|
/// Strips non-alphanumeric chars (except spaces/hyphens) first, then collapses
|
||||||
|
/// whitespace to hyphens. This matches Java's `replaceAll("[^a-z0-9 -]", "")`
|
||||||
|
/// followed by `replaceAll("\\s+", "-")`.
|
||||||
|
/// "King's Cross" → "kings-cross", "Abbey Hey" → "abbey-hey"
|
||||||
pub fn slugify(name: &str) -> String {
|
pub fn slugify(name: &str) -> String {
|
||||||
let mut result = String::with_capacity(name.len());
|
let mut result = String::with_capacity(name.len());
|
||||||
let mut last_was_hyphen = true; // Start true to skip leading hyphens
|
let mut last_was_hyphen = true; // Start true to skip leading hyphens
|
||||||
for ch in name.chars() {
|
for ch in name.chars() {
|
||||||
|
let lower = ch.to_ascii_lowercase();
|
||||||
if ch.is_ascii_alphanumeric() {
|
if ch.is_ascii_alphanumeric() {
|
||||||
result.push(ch.to_ascii_lowercase());
|
result.push(lower);
|
||||||
last_was_hyphen = false;
|
last_was_hyphen = false;
|
||||||
} else if !last_was_hyphen {
|
} else if (ch == ' ' || ch == '-') && !last_was_hyphen {
|
||||||
result.push('-');
|
result.push('-');
|
||||||
last_was_hyphen = true;
|
last_was_hyphen = true;
|
||||||
}
|
}
|
||||||
|
// Other non-alphanumeric chars (apostrophes, ampersands, etc.) are stripped
|
||||||
}
|
}
|
||||||
if result.ends_with('-') {
|
if result.ends_with('-') {
|
||||||
result.pop();
|
result.pop();
|
||||||
|
|
@ -266,6 +271,32 @@ mod tests {
|
||||||
assert_eq!(slugify("London"), "london");
|
assert_eq!(slugify("London"), "london");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slugify_apostrophes_stripped() {
|
||||||
|
assert_eq!(slugify("King's Cross"), "kings-cross");
|
||||||
|
assert_eq!(
|
||||||
|
slugify("Earl's Court tube station"),
|
||||||
|
"earls-court-tube-station"
|
||||||
|
);
|
||||||
|
assert_eq!(slugify("St. Paul's tube station"), "st-pauls-tube-station");
|
||||||
|
assert_eq!(
|
||||||
|
slugify("Regent's Park tube station"),
|
||||||
|
"regents-park-tube-station"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slugify_special_chars_stripped() {
|
||||||
|
assert_eq!(
|
||||||
|
slugify("Cobham & Stoke d'Abernon railway station"),
|
||||||
|
"cobham-stoke-dabernon-railway-station"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slugify("Ravenglass (R&ER) railway station"),
|
||||||
|
"ravenglass-rer-railway-station"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strip_numeric_prefix_basic() {
|
fn strip_numeric_prefix_basic() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use state::AppState;
|
use state::{AppState, SharedState};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
|
|
@ -366,19 +366,19 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
let token_cache = Arc::new(auth::TokenCache::new());
|
let token_cache = Arc::new(auth::TokenCache::new());
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
let app_state = AppState {
|
||||||
data: property_data,
|
data: property_data,
|
||||||
grid,
|
grid,
|
||||||
h3_cells,
|
h3_cells,
|
||||||
poi_data,
|
poi_data: Arc::new(poi_data),
|
||||||
poi_grid,
|
poi_grid: Arc::new(poi_grid),
|
||||||
place_data,
|
place_data: Arc::new(place_data),
|
||||||
postcode_data,
|
postcode_data: Arc::new(postcode_data),
|
||||||
feature_name_to_index,
|
feature_name_to_index,
|
||||||
min_keys,
|
min_keys,
|
||||||
max_keys,
|
max_keys,
|
||||||
avg_keys,
|
avg_keys,
|
||||||
poi_category_groups,
|
poi_category_groups: Arc::new(poi_category_groups),
|
||||||
features_response,
|
features_response,
|
||||||
screenshot_url: cli.screenshot_url,
|
screenshot_url: cli.screenshot_url,
|
||||||
public_url: cli.public_url,
|
public_url: cli.public_url,
|
||||||
|
|
@ -397,14 +397,23 @@ async fn main() -> anyhow::Result<()> {
|
||||||
stripe_secret_key: cli.stripe_secret_key,
|
stripe_secret_key: cli.stripe_secret_key,
|
||||||
stripe_webhook_secret: cli.stripe_webhook_secret,
|
stripe_webhook_secret: cli.stripe_webhook_secret,
|
||||||
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
|
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
let shared = Arc::new(SharedState::new(
|
||||||
|
app_state,
|
||||||
|
cli.properties,
|
||||||
|
cli.postcode_features,
|
||||||
|
cli.listings_buy,
|
||||||
|
cli.listings_rent,
|
||||||
|
));
|
||||||
|
|
||||||
// Start background PocketBase metrics poller (users, saved searches/properties counts)
|
// Start background PocketBase metrics poller (users, saved searches/properties counts)
|
||||||
pocketbase::start_metrics_poller(state.clone());
|
pocketbase::start_metrics_poller(shared.clone());
|
||||||
|
|
||||||
|
let initial_state = shared.load_state();
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(
|
.allow_origin(
|
||||||
state
|
initial_state
|
||||||
.public_url
|
.public_url
|
||||||
.parse::<axum::http::HeaderValue>()
|
.parse::<axum::http::HeaderValue>()
|
||||||
.expect("public_url must be a valid header value"),
|
.expect("public_url must be a valid header value"),
|
||||||
|
|
@ -413,183 +422,156 @@ async fn main() -> anyhow::Result<()> {
|
||||||
.allow_headers(AllowHeaders::mirror_request())
|
.allow_headers(AllowHeaders::mirror_request())
|
||||||
.allow_credentials(true);
|
.allow_credentials(true);
|
||||||
|
|
||||||
let state_features = state.clone();
|
// Each route closure captures a clone of `shared` and calls `load_state()`
|
||||||
let state_hexagons = state.clone();
|
// at request time to get the latest `Arc<AppState>`. This enables hot-reload:
|
||||||
let state_postcodes = state.clone();
|
// the reload endpoint swaps in a new AppState, and subsequent requests pick it up.
|
||||||
let state_postcode_lookup = state.clone();
|
macro_rules! s {
|
||||||
let state_pois = state.clone();
|
() => {
|
||||||
let state_poi_categories = state.clone();
|
shared.clone()
|
||||||
let state_hexagon_properties = state.clone();
|
};
|
||||||
let state_hexagon_stats = state.clone();
|
}
|
||||||
let state_screenshot = state.clone();
|
|
||||||
let state_export = state.clone();
|
let (s1, s2, s3, s4, s5, s6) = (s!(), s!(), s!(), s!(), s!(), s!());
|
||||||
let state_crawler = state.clone();
|
let (s7, s8, s9, s10, s11, s12) = (s!(), s!(), s!(), s!(), s!(), s!());
|
||||||
let state_pb = state.clone();
|
let (s13, s14, s15, s16, s17, s18) = (s!(), s!(), s!(), s!(), s!(), s!());
|
||||||
let state_postcode_stats = state.clone();
|
let (s19, s20, s21, s22, s23, s24) = (s!(), s!(), s!(), s!(), s!(), s!());
|
||||||
let state_postcode_properties = state.clone();
|
let (s25, s26, s27, s28, s29) = (s!(), s!(), s!(), s!(), s!());
|
||||||
let state_places = state.clone();
|
let s_crawler = shared.clone();
|
||||||
let state_shorten = state.clone();
|
let s_pb = shared.clone();
|
||||||
let state_short_url = state.clone();
|
let s_reload = shared.clone();
|
||||||
let state_ai_filters = state.clone();
|
|
||||||
let state_streetview = state.clone();
|
|
||||||
let state_newsletter = state.clone();
|
|
||||||
let state_travel_modes = state.clone();
|
|
||||||
let state_travel_destinations = state.clone();
|
|
||||||
let state_checkout = state.clone();
|
|
||||||
let state_stripe_webhook = state.clone();
|
|
||||||
let state_pricing = state.clone();
|
|
||||||
let state_invites_list = state.clone();
|
|
||||||
let state_invites_create = state.clone();
|
|
||||||
let state_invite_get = state.clone();
|
|
||||||
let state_redeem_invite = state.clone();
|
|
||||||
let state_journey = state.clone();
|
|
||||||
let state_telemetry = state.clone();
|
|
||||||
|
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/api/features",
|
"/api/features",
|
||||||
get(move || routes::get_features(state_features.clone())),
|
get(move || routes::get_features(s1.load_state())),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/hexagons",
|
"/api/hexagons",
|
||||||
get(move |ext, query| routes::get_hexagons(state_hexagons.clone(), ext, query)),
|
get(move |ext, query| routes::get_hexagons(s2.load_state(), ext, query)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/postcodes",
|
"/api/postcodes",
|
||||||
get(move |ext, query| routes::get_postcodes(state_postcodes.clone(), ext, query)),
|
get(move |ext, query| routes::get_postcodes(s3.load_state(), ext, query)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/postcode/{postcode}",
|
"/api/postcode/{postcode}",
|
||||||
get(move |path| routes::get_postcode_lookup(state_postcode_lookup.clone(), path)),
|
get(move |path| routes::get_postcode_lookup(s4.load_state(), path)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/pois",
|
"/api/pois",
|
||||||
get(move |query| routes::get_pois(state_pois.clone(), query)),
|
get(move |query| routes::get_pois(s5.load_state(), query)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/poi-categories",
|
"/api/poi-categories",
|
||||||
get(move || routes::get_poi_categories(state_poi_categories.clone())),
|
get(move || routes::get_poi_categories(s6.load_state())),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/places",
|
"/api/places",
|
||||||
get(move |query| routes::get_places(state_places.clone(), query)),
|
get(move |query| routes::get_places(s7.load_state(), query)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/travel-modes",
|
"/api/travel-modes",
|
||||||
get(move || routes::get_travel_modes(state_travel_modes.clone())),
|
get(move || routes::get_travel_modes(s8.load_state())),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/travel-destinations",
|
"/api/travel-destinations",
|
||||||
get(move |query| {
|
get(move |query| routes::get_travel_destinations(s9.load_state(), query)),
|
||||||
routes::get_travel_destinations(state_travel_destinations.clone(), query)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/journey",
|
"/api/journey",
|
||||||
get(move |query| routes::get_journey(state_journey.clone(), query)),
|
get(move |query| routes::get_journey(s10.load_state(), query)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/hexagon-properties",
|
"/api/hexagon-properties",
|
||||||
get(move |ext, query| {
|
get(move |ext, query| routes::get_hexagon_properties(s11.load_state(), ext, query)),
|
||||||
routes::get_hexagon_properties(state_hexagon_properties.clone(), ext, query)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/hexagon-stats",
|
"/api/hexagon-stats",
|
||||||
get(move |ext, query| {
|
get(move |ext, query| routes::get_hexagon_stats(s12.load_state(), ext, query)),
|
||||||
routes::get_hexagon_stats(state_hexagon_stats.clone(), ext, query)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/postcode-stats",
|
"/api/postcode-stats",
|
||||||
get(move |ext, query| {
|
get(move |ext, query| routes::get_postcode_stats(s13.load_state(), ext, query)),
|
||||||
routes::get_postcode_stats(state_postcode_stats.clone(), ext, query)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/postcode-properties",
|
"/api/postcode-properties",
|
||||||
get(move |ext, query| {
|
get(move |ext, query| routes::get_postcode_properties(s14.load_state(), ext, query)),
|
||||||
routes::get_postcode_properties(state_postcode_properties.clone(), ext, query)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/screenshot",
|
"/api/screenshot",
|
||||||
get(move |headers, query| {
|
get(move |headers, query| routes::get_screenshot(s15.load_state(), headers, query)),
|
||||||
routes::get_screenshot(state_screenshot.clone(), headers, query)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/export",
|
"/api/export",
|
||||||
get(move |headers, ext, query| {
|
get(move |headers, ext, query| {
|
||||||
routes::get_export(state_export.clone(), headers, ext, query)
|
routes::get_export(s16.load_state(), headers, ext, query)
|
||||||
})
|
})
|
||||||
.layer(ConcurrencyLimitLayer::new(3)),
|
.layer(ConcurrencyLimitLayer::new(3)),
|
||||||
)
|
)
|
||||||
.route("/api/me", get(routes::get_me))
|
.route("/api/me", get(routes::get_me))
|
||||||
.route(
|
.route(
|
||||||
"/api/shorten",
|
"/api/shorten",
|
||||||
post(move |body| routes::post_shorten(state_shorten.clone(), body)),
|
post(move |body| routes::post_shorten(s17.load_state(), body)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/ai-filters",
|
"/api/ai-filters",
|
||||||
post(move |ext, body| routes::post_ai_filters(state_ai_filters.clone(), ext, body))
|
post(move |ext, body| routes::post_ai_filters(s18.load_state(), ext, body))
|
||||||
.layer(ConcurrencyLimitLayer::new(5)),
|
.layer(ConcurrencyLimitLayer::new(5)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/streetview",
|
"/api/streetview",
|
||||||
get(move |query| routes::get_streetview(state_streetview.clone(), query)),
|
get(move |query| routes::get_streetview(s19.load_state(), query)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/newsletter",
|
"/api/newsletter",
|
||||||
patch(move |ext, body| routes::patch_newsletter(state_newsletter.clone(), ext, body)),
|
patch(move |ext, body| routes::patch_newsletter(s20.load_state(), ext, body)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/pricing",
|
"/api/pricing",
|
||||||
get(move || routes::get_pricing(state_pricing.clone())),
|
get(move || routes::get_pricing(s21.load_state())),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/checkout",
|
"/api/checkout",
|
||||||
post(move |ext, body| routes::post_checkout(state_checkout.clone(), ext, body))
|
post(move |ext, body| routes::post_checkout(s22.load_state(), ext, body))
|
||||||
.layer(ConcurrencyLimitLayer::new(10)),
|
.layer(ConcurrencyLimitLayer::new(10)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/stripe-webhook",
|
"/api/stripe-webhook",
|
||||||
post(move |headers, body| {
|
post(move |headers, body| routes::post_stripe_webhook(s23.load_state(), headers, body)),
|
||||||
routes::post_stripe_webhook(state_stripe_webhook.clone(), headers, body)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/invites",
|
"/api/invites",
|
||||||
get(move |ext| routes::get_invites(state_invites_list.clone(), ext)).post(
|
get(move |ext| routes::get_invites(s24.load_state(), ext))
|
||||||
move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body),
|
.post(move |ext, body| routes::post_invites(s25.load_state(), ext, body)),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/invite/{code}",
|
"/api/invite/{code}",
|
||||||
get(move |ext, path| routes::get_invite(state_invite_get.clone(), ext, path)),
|
get(move |ext, path| routes::get_invite(s26.load_state(), ext, path)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/redeem-invite",
|
"/api/redeem-invite",
|
||||||
post(move |ext, body| {
|
post(move |ext, body| routes::post_redeem_invite(s27.load_state(), ext, body)),
|
||||||
routes::post_redeem_invite(state_redeem_invite.clone(), ext, body)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/s/{code}",
|
"/s/{code}",
|
||||||
get(move |path| routes::get_short_url(state_short_url.clone(), path)),
|
get(move |path| routes::get_short_url(s28.load_state(), path)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/telemetry",
|
"/api/telemetry",
|
||||||
post(move |ext, headers, body| {
|
post(move |ext, headers, body| {
|
||||||
let _ = state_telemetry.clone();
|
let _ = s29.load_state();
|
||||||
routes::post_telemetry(ext, headers, body)
|
routes::post_telemetry(ext, headers, body)
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/reload",
|
||||||
|
post(move || routes::post_reload(s_reload.clone())),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add tile routes
|
// Add tile routes
|
||||||
let reader_tile = tile_reader.clone();
|
let reader_tile = tile_reader.clone();
|
||||||
let reader_style = tile_reader.clone();
|
let reader_style = tile_reader.clone();
|
||||||
let public_url_tiles = state.public_url.clone();
|
let public_url_tiles = initial_state.public_url.clone();
|
||||||
let api = api
|
let api = api
|
||||||
.route(
|
.route(
|
||||||
"/api/tiles/{z}/{x}/{y}",
|
"/api/tiles/{z}/{x}/{y}",
|
||||||
|
|
@ -609,7 +591,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/pb/{*rest}",
|
"/pb/{*rest}",
|
||||||
any(move |req| routes::proxy_to_pocketbase(state_pb.clone(), req)),
|
any(move |req| routes::proxy_to_pocketbase(s_pb.load_state(), req)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let app = if let Some(ref dist) = cli.dist {
|
let app = if let Some(ref dist) = cli.dist {
|
||||||
|
|
@ -621,7 +603,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
.layer(middleware::from_fn(auth::auth_middleware))
|
.layer(middleware::from_fn(auth::auth_middleware))
|
||||||
.layer(middleware::from_fn(
|
.layer(middleware::from_fn(
|
||||||
move |req: axum::extract::Request, next: middleware::Next| {
|
move |req: axum::extract::Request, next: middleware::Next| {
|
||||||
let st = state_crawler.clone();
|
let st = s_crawler.load_state();
|
||||||
async move {
|
async move {
|
||||||
// Inject state into request extensions for auth + OG middleware
|
// Inject state into request extensions for auth + OG middleware
|
||||||
let (mut parts, body) = req.into_parts();
|
let (mut parts, body) = req.into_parts();
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,47 @@ use axum::http::StatusCode;
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use metrics::{counter, gauge, histogram};
|
use metrics::{counter, gauge, histogram};
|
||||||
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
|
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
/// Initialize the Prometheus metrics exporter and return a handle for rendering metrics.
|
/// Initialize the Prometheus metrics exporter and return a handle for rendering metrics.
|
||||||
|
///
|
||||||
|
/// Configures histogram bucket boundaries so the exporter renders Prometheus histograms
|
||||||
|
/// (with `_bucket` suffix) instead of summaries. Without this, `histogram_quantile()`
|
||||||
|
/// queries in Grafana find no `_bucket` metrics and return empty.
|
||||||
pub fn init_metrics() -> PrometheusHandle {
|
pub fn init_metrics() -> PrometheusHandle {
|
||||||
|
// Standard Prometheus buckets for HTTP latencies (seconds)
|
||||||
|
const LATENCY_BUCKETS: &[f64] = &[
|
||||||
|
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
|
||||||
|
];
|
||||||
|
// Wider buckets for screenshot generation (can take 30s+)
|
||||||
|
const SCREENSHOT_BUCKETS: &[f64] = &[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0];
|
||||||
|
// Count-based buckets for response sizes (number of hexagons/postcodes returned)
|
||||||
|
const RESPONSE_COUNT_BUCKETS: &[f64] = &[
|
||||||
|
10.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0,
|
||||||
|
];
|
||||||
|
|
||||||
PrometheusBuilder::new()
|
PrometheusBuilder::new()
|
||||||
|
.set_buckets_for_metric(
|
||||||
|
Matcher::Full("http_request_duration_seconds".to_string()),
|
||||||
|
LATENCY_BUCKETS,
|
||||||
|
)
|
||||||
|
.expect("Failed to set HTTP latency buckets")
|
||||||
|
.set_buckets_for_metric(
|
||||||
|
Matcher::Full("screenshot_duration_seconds".to_string()),
|
||||||
|
SCREENSHOT_BUCKETS,
|
||||||
|
)
|
||||||
|
.expect("Failed to set screenshot duration buckets")
|
||||||
|
.set_buckets_for_metric(
|
||||||
|
Matcher::Full("hexagons_response_count".to_string()),
|
||||||
|
RESPONSE_COUNT_BUCKETS,
|
||||||
|
)
|
||||||
|
.expect("Failed to set hexagons response count buckets")
|
||||||
|
.set_buckets_for_metric(
|
||||||
|
Matcher::Full("postcodes_response_count".to_string()),
|
||||||
|
RESPONSE_COUNT_BUCKETS,
|
||||||
|
)
|
||||||
|
.expect("Failed to set postcodes response count buckets")
|
||||||
.install_recorder()
|
.install_recorder()
|
||||||
.expect("Failed to install Prometheus recorder")
|
.expect("Failed to install Prometheus recorder")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ use axum::http::StatusCode;
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
|
|
||||||
/// Parse an optional `?fields=` query param into feature indices for selective aggregation.
|
/// Parse an optional `?fields=` query param into feature indices for selective aggregation.
|
||||||
/// Returns `None` if fields is `None` (all features included), or `Some(indices)` if specified.
|
/// Returns `None` if fields param is absent (all features included).
|
||||||
/// Returns an error if any field name is unknown.
|
/// Returns `Some(vec![])` if fields is present but empty (no features — count only).
|
||||||
|
/// Returns `Some(indices)` for named fields. Errors on unknown field names.
|
||||||
pub fn parse_field_indices(
|
pub fn parse_field_indices(
|
||||||
fields: Option<&str>,
|
fields: Option<&str>,
|
||||||
name_to_index: &FxHashMap<String, usize>,
|
name_to_index: &FxHashMap<String, usize>,
|
||||||
|
|
@ -14,7 +15,7 @@ pub fn parse_field_indices(
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
if fields_str.is_empty() {
|
if fields_str.is_empty() {
|
||||||
return Ok(None);
|
return Ok(Some(vec![]));
|
||||||
}
|
}
|
||||||
let mut indices = Vec::new();
|
let mut indices = Vec::new();
|
||||||
for name in fields_str.split(',') {
|
for name in fields_str.split(',') {
|
||||||
|
|
|
||||||
|
|
@ -763,11 +763,12 @@ pub async fn ensure_oauth_providers(
|
||||||
|
|
||||||
/// Spawn a background task that polls PocketBase every 60 seconds for collection counts
|
/// Spawn a background task that polls PocketBase every 60 seconds for collection counts
|
||||||
/// and exposes them as Prometheus gauges.
|
/// and exposes them as Prometheus gauges.
|
||||||
pub fn start_metrics_poller(state: Arc<AppState>) {
|
pub fn start_metrics_poller(shared: Arc<crate::state::SharedState>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
|
let state = shared.load_state();
|
||||||
poll_pocketbase_counts(&state).await;
|
poll_pocketbase_counts(&state).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -815,7 +816,7 @@ async fn poll_pocketbase_counts(state: &AppState) {
|
||||||
("type", "referral"),
|
("type", "referral"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
Some(r##"used_by_id!=""##),
|
Some(r#"used_by_id!="""#),
|
||||||
"invites_total",
|
"invites_total",
|
||||||
("type", "redeemed"),
|
("type", "redeemed"),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ mod postcode_stats;
|
||||||
mod postcodes;
|
mod postcodes;
|
||||||
pub(crate) mod pricing;
|
pub(crate) mod pricing;
|
||||||
pub(crate) mod properties;
|
pub(crate) mod properties;
|
||||||
|
mod reload;
|
||||||
mod screenshot;
|
mod screenshot;
|
||||||
mod shorten;
|
mod shorten;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
|
@ -45,6 +46,7 @@ pub use postcode_stats::get_postcode_stats;
|
||||||
pub use postcodes::{get_postcode_lookup, get_postcodes};
|
pub use postcodes::{get_postcode_lookup, get_postcodes};
|
||||||
pub use pricing::get_pricing;
|
pub use pricing::get_pricing;
|
||||||
pub use properties::get_hexagon_properties;
|
pub use properties::get_hexagon_properties;
|
||||||
|
pub use reload::post_reload;
|
||||||
pub use screenshot::{fetch_screenshot_bytes, get_screenshot};
|
pub use screenshot::{fetch_screenshot_bytes, get_screenshot};
|
||||||
pub use shorten::{get_short_url, post_shorten};
|
pub use shorten::{get_short_url, post_shorten};
|
||||||
pub use streetview::get_streetview;
|
pub use streetview::get_streetview;
|
||||||
|
|
|
||||||
|
|
@ -510,14 +510,6 @@ pub async fn post_ai_filters(
|
||||||
.0
|
.0
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Login required".into()))?;
|
.ok_or((StatusCode::UNAUTHORIZED, "Login required".into()))?;
|
||||||
|
|
||||||
// Email verification check (skipped in dev mode)
|
|
||||||
if !user.verified && !state.is_dev {
|
|
||||||
return Err((
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"Please verify your email to use AI filters".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check weekly token usage
|
// Check weekly token usage
|
||||||
let current_week = current_week_number();
|
let current_week = current_week_number();
|
||||||
let (stored_tokens, stored_week) = fetch_ai_usage(&state, &user.id).await?;
|
let (stored_tokens, stored_week) = fetch_ai_usage(&state, &user.id).await?;
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ use crate::consts::{DEMO_BOUNDS, MAX_CELLS_PER_REQUEST};
|
||||||
use crate::data::travel_time::TravelData;
|
use crate::data::travel_time::TravelData;
|
||||||
use crate::licensing::check_license_bounds;
|
use crate::licensing::check_license_bounds;
|
||||||
use crate::parsing::{
|
use crate::parsing::{
|
||||||
bounds_intersect, cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_indices,
|
cell_for_row_cached, needs_parent, parse_field_indices, parse_filters, require_bounds,
|
||||||
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
|
row_passes_filters, validate_h3_resolution,
|
||||||
};
|
};
|
||||||
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
|
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
@ -26,6 +26,28 @@ use crate::state::AppState;
|
||||||
/// Row count threshold above which we use rayon parallel aggregation.
|
/// Row count threshold above which we use rayon parallel aggregation.
|
||||||
const PARALLEL_THRESHOLD: usize = 50_000;
|
const PARALLEL_THRESHOLD: usize = 50_000;
|
||||||
|
|
||||||
|
/// Per-thread aggregation result: feature accumulators + travel time accumulators.
|
||||||
|
type ChunkResult = (
|
||||||
|
FxHashMap<u64, Aggregator>,
|
||||||
|
Vec<FxHashMap<u64, TravelTimeAgg>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Maximum center-to-vertex distance in degrees per H3 resolution.
|
||||||
|
/// Generous for UK latitudes (49°–61°) so we never false-exclude a visible cell.
|
||||||
|
/// Used for cheap center-based bounds filtering instead of computing full cell boundary.
|
||||||
|
const H3_CENTER_BUFFERS: [f64; 13] = [
|
||||||
|
5.0, 2.0, 1.0, 0.5, // res 0–3 (unused in practice)
|
||||||
|
0.50, // res 4
|
||||||
|
0.20, // res 5
|
||||||
|
0.08, // res 6
|
||||||
|
0.03, // res 7
|
||||||
|
0.012, // res 8
|
||||||
|
0.005, // res 9
|
||||||
|
0.002, // res 10
|
||||||
|
0.001, // res 11
|
||||||
|
0.0005, // res 12
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct HexagonsResponse {
|
pub struct HexagonsResponse {
|
||||||
features: Vec<Map<String, Value>>,
|
features: Vec<Map<String, Value>>,
|
||||||
|
|
@ -45,7 +67,10 @@ pub struct HexagonParams {
|
||||||
travel: Option<String>,
|
travel: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
|
/// Build feature maps from aggregated cell data, filtering to only cells whose
|
||||||
|
/// center is within the query bounds (expanded by a resolution-dependent buffer).
|
||||||
|
/// This is much cheaper than the previous approach of computing full cell boundaries
|
||||||
|
/// (6 vertices per cell) — just 4 float comparisons per cell.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_feature_maps(
|
fn build_feature_maps(
|
||||||
groups: &FxHashMap<u64, Aggregator>,
|
groups: &FxHashMap<u64, Aggregator>,
|
||||||
|
|
@ -55,44 +80,69 @@ fn build_feature_maps(
|
||||||
num_features: usize,
|
num_features: usize,
|
||||||
indices: Option<&[usize]>,
|
indices: Option<&[usize]>,
|
||||||
query_bounds: (f64, f64, f64, f64),
|
query_bounds: (f64, f64, f64, f64),
|
||||||
|
resolution: h3o::Resolution,
|
||||||
travel_aggs: &[FxHashMap<u64, TravelTimeAgg>],
|
travel_aggs: &[FxHashMap<u64, TravelTimeAgg>],
|
||||||
travel_field_keys: &[String],
|
travel_field_keys: &[String],
|
||||||
) -> Vec<Map<String, Value>> {
|
) -> Vec<Map<String, Value>> {
|
||||||
let mut features = Vec::with_capacity(groups.len());
|
let mut features = Vec::with_capacity(groups.len());
|
||||||
let (q_south, q_west, q_north, q_east) = query_bounds;
|
let (q_south, q_west, q_north, q_east) = query_bounds;
|
||||||
|
|
||||||
|
// Expand bounds by resolution-dependent buffer for center-based filtering
|
||||||
|
let buf = H3_CENTER_BUFFERS[resolution as usize];
|
||||||
|
let bound_south = q_south - buf;
|
||||||
|
let bound_north = q_north + buf;
|
||||||
|
let bound_west = q_west - buf;
|
||||||
|
let bound_east = q_east + buf;
|
||||||
|
|
||||||
|
// Pre-compute travel time key strings (avoids per-cell format!())
|
||||||
|
let travel_keys: Vec<(String, String, String)> = travel_field_keys
|
||||||
|
.iter()
|
||||||
|
.map(|key| {
|
||||||
|
(
|
||||||
|
format!("min_{key}"),
|
||||||
|
format!("max_{key}"),
|
||||||
|
format!("avg_{key}"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Pre-compute default feature indices to avoid per-cell Box<dyn Iterator> allocation
|
||||||
|
let default_indices: Vec<usize>;
|
||||||
|
let feat_indices: &[usize] = match indices {
|
||||||
|
Some(idx) => idx,
|
||||||
|
None => {
|
||||||
|
default_indices = (0..num_features).collect();
|
||||||
|
&default_indices
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for (&cell_id, aggregation) in groups {
|
for (&cell_id, aggregation) in groups {
|
||||||
let Some(cell) = h3o::CellIndex::try_from(cell_id).ok() else {
|
let Some(cell) = h3o::CellIndex::try_from(cell_id).ok() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter out cells that don't intersect the query bounds
|
// Center is already needed for lat/lon output — reuse for bounds check
|
||||||
let (c_south, c_west, c_north, c_east) = h3_cell_bounds(cell, 0.0);
|
let center: h3o::LatLng = cell.into();
|
||||||
if !bounds_intersect(
|
let lat = center.lat();
|
||||||
c_south, c_west, c_north, c_east, q_south, q_west, q_north, q_east,
|
let lng = center.lng();
|
||||||
) {
|
|
||||||
|
// Center-based bounds check: 4 comparisons instead of computing 6 boundary vertices
|
||||||
|
if lat < bound_south || lat > bound_north || lng < bound_west || lng > bound_east {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut map = Map::new();
|
let mut map = Map::new();
|
||||||
map.insert("h3".into(), Value::String(cell.to_string()));
|
map.insert("h3".into(), Value::String(cell.to_string()));
|
||||||
map.insert("count".into(), Value::Number(aggregation.count.into()));
|
map.insert("count".into(), Value::Number(aggregation.count.into()));
|
||||||
let center: h3o::LatLng = cell.into();
|
if let (Some(lat_num), Some(lon_num)) = (
|
||||||
if let (Some(lat), Some(lon)) = (
|
serde_json::Number::from_f64(lat),
|
||||||
serde_json::Number::from_f64(center.lat()),
|
serde_json::Number::from_f64(lng),
|
||||||
serde_json::Number::from_f64(center.lng()),
|
|
||||||
) {
|
) {
|
||||||
map.insert("lat".into(), Value::Number(lat));
|
map.insert("lat".into(), Value::Number(lat_num));
|
||||||
map.insert("lon".into(), Value::Number(lon));
|
map.insert("lon".into(), Value::Number(lon_num));
|
||||||
}
|
}
|
||||||
|
|
||||||
let iter: Box<dyn Iterator<Item = usize>> = if let Some(idx) = indices {
|
for &feat_index in feat_indices {
|
||||||
Box::new(idx.iter().copied())
|
|
||||||
} else {
|
|
||||||
Box::new(0..num_features)
|
|
||||||
};
|
|
||||||
|
|
||||||
for feat_index in iter {
|
|
||||||
if aggregation.feat_counts[feat_index] > 0 {
|
if aggregation.feat_counts[feat_index] > 0 {
|
||||||
let avg = aggregation.sums[feat_index] / aggregation.feat_counts[feat_index] as f64;
|
let avg = aggregation.sums[feat_index] / aggregation.feat_counts[feat_index] as f64;
|
||||||
if let (Some(min_num), Some(max_num), Some(avg_num)) = (
|
if let (Some(min_num), Some(max_num), Some(avg_num)) = (
|
||||||
|
|
@ -107,20 +157,19 @@ fn build_feature_maps(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add travel time aggregation fields
|
// Add travel time aggregation fields (using pre-computed key strings)
|
||||||
for (ti, agg_map) in travel_aggs.iter().enumerate() {
|
for (ti, agg_map) in travel_aggs.iter().enumerate() {
|
||||||
if let Some(agg) = agg_map.get(&cell_id) {
|
if let Some(agg) = agg_map.get(&cell_id) {
|
||||||
if agg.count > 0 {
|
if agg.count > 0 {
|
||||||
let key = &travel_field_keys[ti];
|
|
||||||
let avg = agg.sum / agg.count as f64;
|
let avg = agg.sum / agg.count as f64;
|
||||||
if let Some(nm) = serde_json::Number::from_f64(agg.min as f64) {
|
if let Some(nm) = serde_json::Number::from_f64(agg.min as f64) {
|
||||||
map.insert(format!("min_{key}"), Value::Number(nm));
|
map.insert(travel_keys[ti].0.clone(), Value::Number(nm));
|
||||||
}
|
}
|
||||||
if let Some(nm) = serde_json::Number::from_f64(agg.max as f64) {
|
if let Some(nm) = serde_json::Number::from_f64(agg.max as f64) {
|
||||||
map.insert(format!("max_{key}"), Value::Number(nm));
|
map.insert(travel_keys[ti].1.clone(), Value::Number(nm));
|
||||||
}
|
}
|
||||||
if let Some(nm) = serde_json::Number::from_f64(avg) {
|
if let Some(nm) = serde_json::Number::from_f64(avg) {
|
||||||
map.insert(format!("avg_{key}"), Value::Number(nm));
|
map.insert(travel_keys[ti].2.clone(), Value::Number(nm));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -207,19 +256,31 @@ pub async fn get_hexagons(
|
||||||
.map(|_| FxHashMap::default())
|
.map(|_| FxHashMap::default())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Collect row indices for threshold-based sequential/parallel aggregation
|
// O(grid cells) count — no allocation. Used for parallel threshold decision.
|
||||||
let row_indices = state.grid.query(south, west, north, east);
|
let row_count = state.grid.count_in_bounds(south, west, north, east);
|
||||||
|
let t_grid = t0.elapsed();
|
||||||
|
|
||||||
if row_indices.len() >= PARALLEL_THRESHOLD && !has_travel {
|
let parallel = row_count >= PARALLEL_THRESHOLD;
|
||||||
// Parallel path: split rows across rayon threads, each with local accumulators
|
|
||||||
|
if parallel {
|
||||||
|
// Parallel: collect row indices for par_chunks, split across rayon threads.
|
||||||
|
// Now handles travel time too (postcode interner & travel data are thread-safe).
|
||||||
|
let row_indices = state.grid.query(south, west, north, east);
|
||||||
let chunk_size = (row_indices.len() / rayon::current_num_threads()).max(1000);
|
let chunk_size = (row_indices.len() / rayon::current_num_threads()).max(1000);
|
||||||
|
|
||||||
let thread_results: Vec<FxHashMap<u64, Aggregator>> = row_indices
|
let thread_results: Vec<ChunkResult> = row_indices
|
||||||
.par_chunks(chunk_size)
|
.par_chunks(chunk_size)
|
||||||
.map(|chunk| {
|
.map(|chunk| {
|
||||||
let mut local_groups: FxHashMap<u64, Aggregator> = FxHashMap::default();
|
let mut local_groups: FxHashMap<u64, Aggregator> = FxHashMap::default();
|
||||||
|
let mut local_travel_aggs: Vec<FxHashMap<u64, TravelTimeAgg>> = (0
|
||||||
|
..travel_entries.len())
|
||||||
|
.map(|_| FxHashMap::default())
|
||||||
|
.collect();
|
||||||
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
|
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
|
||||||
for &row_idx in chunk {
|
let mut travel_minutes: Vec<Option<i16>> =
|
||||||
|
Vec::with_capacity(travel_entries.len());
|
||||||
|
|
||||||
|
'row: for &row_idx in chunk {
|
||||||
let row = row_idx as usize;
|
let row = row_idx as usize;
|
||||||
if !row_passes_filters(
|
if !row_passes_filters(
|
||||||
row,
|
row,
|
||||||
|
|
@ -230,6 +291,32 @@ pub async fn get_hexagons(
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if has_travel {
|
||||||
|
travel_minutes.clear();
|
||||||
|
let postcode = pc_interner.resolve(&pc_keys[row]);
|
||||||
|
for (ti, entry) in travel_entries.iter().enumerate() {
|
||||||
|
let row_data = travel_data[ti].get(postcode);
|
||||||
|
let minutes = row_data.map(|r| {
|
||||||
|
if entry.use_best {
|
||||||
|
r.best_minutes.unwrap_or(r.minutes)
|
||||||
|
} else {
|
||||||
|
r.minutes
|
||||||
|
}
|
||||||
|
});
|
||||||
|
travel_minutes.push(minutes);
|
||||||
|
if let (Some(fmin), Some(fmax)) =
|
||||||
|
(entry.filter_min, entry.filter_max)
|
||||||
|
{
|
||||||
|
match minutes {
|
||||||
|
Some(mins)
|
||||||
|
if (mins as f32) >= fmin && (mins as f32) <= fmax => {}
|
||||||
|
_ => continue 'row,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let cell_id = cell_for_row_cached(
|
let cell_id = cell_for_row_cached(
|
||||||
row,
|
row,
|
||||||
precomputed,
|
precomputed,
|
||||||
|
|
@ -237,6 +324,7 @@ pub async fn get_hexagons(
|
||||||
need_parent,
|
need_parent,
|
||||||
&mut h3_cache,
|
&mut h3_cache,
|
||||||
);
|
);
|
||||||
|
|
||||||
let agg = local_groups
|
let agg = local_groups
|
||||||
.entry(cell_id)
|
.entry(cell_id)
|
||||||
.or_insert_with(|| Aggregator::new(num_features));
|
.or_insert_with(|| Aggregator::new(num_features));
|
||||||
|
|
@ -251,91 +339,108 @@ pub async fn get_hexagons(
|
||||||
} else {
|
} else {
|
||||||
agg.add_row(feature_data, row, num_features, &quant);
|
agg.add_row(feature_data, row, num_features, &quant);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
local_groups
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Merge thread-local results into the main groups map
|
for (ti, minutes) in travel_minutes.iter().enumerate() {
|
||||||
for local_groups in thread_results {
|
if let Some(mins) = minutes {
|
||||||
for (cell_id, local_agg) in local_groups {
|
let tagg = local_travel_aggs[ti]
|
||||||
let agg = groups
|
.entry(cell_id)
|
||||||
.entry(cell_id)
|
.or_insert_with(TravelTimeAgg::new);
|
||||||
.or_insert_with(|| Aggregator::new(num_features));
|
tagg.add(*mins as f32);
|
||||||
agg.merge(&local_agg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Sequential path (also handles travel time which needs postcode lookups)
|
|
||||||
let mut travel_minutes: Vec<Option<i16>> = Vec::with_capacity(travel_entries.len());
|
|
||||||
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
|
|
||||||
|
|
||||||
'row: for &row_idx in &row_indices {
|
|
||||||
let row = row_idx as usize;
|
|
||||||
|
|
||||||
// Regular filters
|
|
||||||
if !row_passes_filters(
|
|
||||||
row,
|
|
||||||
&parsed_filters,
|
|
||||||
&parsed_enum_filters,
|
|
||||||
feature_data,
|
|
||||||
num_features,
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Travel time filter: check each entry with a range
|
|
||||||
if has_travel {
|
|
||||||
travel_minutes.clear();
|
|
||||||
let postcode = pc_interner.resolve(&pc_keys[row]);
|
|
||||||
for (ti, entry) in travel_entries.iter().enumerate() {
|
|
||||||
let row_data = travel_data[ti].get(postcode);
|
|
||||||
let minutes = row_data.map(|r| {
|
|
||||||
if entry.use_best {
|
|
||||||
r.best_minutes.unwrap_or(r.minutes)
|
|
||||||
} else {
|
|
||||||
r.minutes
|
|
||||||
}
|
|
||||||
});
|
|
||||||
travel_minutes.push(minutes);
|
|
||||||
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
|
|
||||||
match minutes {
|
|
||||||
Some(mins) if (mins as f32) >= fmin && (mins as f32) <= fmax => {}
|
|
||||||
_ => continue 'row, // Filtered out (jump to next row_idx)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(local_groups, local_travel_aggs)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Merge thread-local results into the main accumulators
|
||||||
|
for (local_groups, local_travel) in thread_results {
|
||||||
|
for (cell_id, local_agg) in local_groups {
|
||||||
|
groups
|
||||||
|
.entry(cell_id)
|
||||||
|
.or_insert_with(|| Aggregator::new(num_features))
|
||||||
|
.merge(&local_agg);
|
||||||
}
|
}
|
||||||
|
for (ti, local_ta) in local_travel.into_iter().enumerate() {
|
||||||
let cell_id =
|
for (cell_id, local_tt) in local_ta {
|
||||||
cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache);
|
travel_aggs[ti]
|
||||||
|
|
||||||
// Aggregate regular features
|
|
||||||
let aggregation = groups
|
|
||||||
.entry(cell_id)
|
|
||||||
.or_insert_with(|| Aggregator::new(num_features));
|
|
||||||
if let Some(sel_indices) = field_indices.as_deref() {
|
|
||||||
aggregation.add_row_selective(
|
|
||||||
feature_data,
|
|
||||||
row,
|
|
||||||
num_features,
|
|
||||||
sel_indices,
|
|
||||||
&quant,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
aggregation.add_row(feature_data, row, num_features, &quant);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregate travel time
|
|
||||||
for (ti, minutes) in travel_minutes.iter().enumerate() {
|
|
||||||
if let Some(mins) = minutes {
|
|
||||||
let agg = travel_aggs[ti]
|
|
||||||
.entry(cell_id)
|
.entry(cell_id)
|
||||||
.or_insert_with(TravelTimeAgg::new);
|
.or_insert_with(TravelTimeAgg::new)
|
||||||
agg.add(*mins as f32);
|
.merge(&local_tt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Sequential: use for_each_in_bounds to avoid Vec<u32> allocation
|
||||||
|
let mut travel_minutes: Vec<Option<i16>> = Vec::with_capacity(travel_entries.len());
|
||||||
|
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
|
||||||
|
|
||||||
|
state
|
||||||
|
.grid
|
||||||
|
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
||||||
|
let row = row_idx as usize;
|
||||||
|
|
||||||
|
if !row_passes_filters(
|
||||||
|
row,
|
||||||
|
&parsed_filters,
|
||||||
|
&parsed_enum_filters,
|
||||||
|
feature_data,
|
||||||
|
num_features,
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_travel {
|
||||||
|
travel_minutes.clear();
|
||||||
|
let postcode = pc_interner.resolve(&pc_keys[row]);
|
||||||
|
for (ti, entry) in travel_entries.iter().enumerate() {
|
||||||
|
let row_data = travel_data[ti].get(postcode);
|
||||||
|
let minutes = row_data.map(|r| {
|
||||||
|
if entry.use_best {
|
||||||
|
r.best_minutes.unwrap_or(r.minutes)
|
||||||
|
} else {
|
||||||
|
r.minutes
|
||||||
|
}
|
||||||
|
});
|
||||||
|
travel_minutes.push(minutes);
|
||||||
|
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
|
||||||
|
match minutes {
|
||||||
|
Some(mins)
|
||||||
|
if (mins as f32) >= fmin && (mins as f32) <= fmax => {}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cell_id =
|
||||||
|
cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache);
|
||||||
|
|
||||||
|
let aggregation = groups
|
||||||
|
.entry(cell_id)
|
||||||
|
.or_insert_with(|| Aggregator::new(num_features));
|
||||||
|
if let Some(sel_indices) = field_indices.as_deref() {
|
||||||
|
aggregation.add_row_selective(
|
||||||
|
feature_data,
|
||||||
|
row,
|
||||||
|
num_features,
|
||||||
|
sel_indices,
|
||||||
|
&quant,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
aggregation.add_row(feature_data, row, num_features, &quant);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ti, minutes) in travel_minutes.iter().enumerate() {
|
||||||
|
if let Some(mins) = minutes {
|
||||||
|
let agg = travel_aggs[ti]
|
||||||
|
.entry(cell_id)
|
||||||
|
.or_insert_with(TravelTimeAgg::new);
|
||||||
|
agg.add(*mins as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let t_agg = t0.elapsed();
|
let t_agg = t0.elapsed();
|
||||||
|
|
@ -348,6 +453,7 @@ pub async fn get_hexagons(
|
||||||
num_features,
|
num_features,
|
||||||
field_indices.as_deref(),
|
field_indices.as_deref(),
|
||||||
(south, west, north, east),
|
(south, west, north, east),
|
||||||
|
h3_res,
|
||||||
&travel_aggs,
|
&travel_aggs,
|
||||||
&travel_field_keys,
|
&travel_field_keys,
|
||||||
);
|
);
|
||||||
|
|
@ -357,11 +463,10 @@ pub async fn get_hexagons(
|
||||||
features.truncate(MAX_CELLS_PER_REQUEST);
|
features.truncate(MAX_CELLS_PER_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
let parallel = row_indices.len() >= PARALLEL_THRESHOLD && !has_travel;
|
|
||||||
let t_total = t0.elapsed();
|
let t_total = t0.elapsed();
|
||||||
info!(
|
info!(
|
||||||
resolution,
|
resolution,
|
||||||
rows = row_indices.len(),
|
rows = row_count,
|
||||||
parallel,
|
parallel,
|
||||||
cells_before_filter = groups.len(),
|
cells_before_filter = groups.len(),
|
||||||
cells_after_filter = features.len(),
|
cells_after_filter = features.len(),
|
||||||
|
|
@ -369,8 +474,11 @@ pub async fn get_hexagons(
|
||||||
bounds = format_args!("{:.4},{:.4},{:.4},{:.4}", south, west, north, east),
|
bounds = format_args!("{:.4},{:.4},{:.4},{:.4}", south, west, north, east),
|
||||||
filters = num_filters,
|
filters = num_filters,
|
||||||
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
||||||
|
fields = field_indices.as_ref().map(|v| v.len() as i32).unwrap_or(-1),
|
||||||
travel_entries = travel_entries.len(),
|
travel_entries = travel_entries.len(),
|
||||||
agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0),
|
grid_ms = format_args!("{:.1}", t_grid.as_secs_f64() * 1000.0),
|
||||||
|
agg_ms = format_args!("{:.1}", (t_agg - t_grid).as_secs_f64() * 1000.0),
|
||||||
|
json_ms = format_args!("{:.1}", (t_total - t_agg).as_secs_f64() * 1000.0),
|
||||||
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
|
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
|
||||||
"GET /api/hexagons"
|
"GET /api/hexagons"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ pub struct POICategoriesResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_poi_categories(state: Arc<AppState>) -> Json<POICategoriesResponse> {
|
pub async fn get_poi_categories(state: Arc<AppState>) -> Json<POICategoriesResponse> {
|
||||||
let groups: Vec<POICategoryGroup> = state.poi_category_groups.clone();
|
let groups: Vec<POICategoryGroup> = state.poi_category_groups.to_vec();
|
||||||
|
|
||||||
let total: usize = groups.iter().map(|group| group.categories.len()).sum();
|
let total: usize = groups.iter().map(|group| group.categories.len()).sum();
|
||||||
info!(
|
info!(
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,8 @@ pub async fn get_postcodes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let t_agg = t0.elapsed();
|
||||||
|
|
||||||
// Build response, filtering postcodes to only those whose polygon intersects query bounds
|
// Build response, filtering postcodes to only those whose polygon intersects query bounds
|
||||||
let mut features = Vec::with_capacity(postcode_aggs.len());
|
let mut features = Vec::with_capacity(postcode_aggs.len());
|
||||||
let postcodes_before_filter = postcode_aggs.len();
|
let postcodes_before_filter = postcode_aggs.len();
|
||||||
|
|
@ -288,7 +290,10 @@ pub async fn get_postcodes(
|
||||||
bounds = format_args!("{:.6},{:.6},{:.6},{:.6}", south, west, north, east),
|
bounds = format_args!("{:.6},{:.6},{:.6},{:.6}", south, west, north, east),
|
||||||
filters = num_filters,
|
filters = num_filters,
|
||||||
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
||||||
|
fields = field_indices.as_ref().map(|v| v.len() as i32).unwrap_or(-1),
|
||||||
travel_entries = travel_entries.len(),
|
travel_entries = travel_entries.len(),
|
||||||
|
agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0),
|
||||||
|
json_ms = format_args!("{:.1}", (t_total - t_agg).as_secs_f64() * 1000.0),
|
||||||
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
|
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
|
||||||
"GET /api/postcodes"
|
"GET /api/postcodes"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
179
server-rs/src/routes/reload.rs
Normal file
179
server-rs/src/routes/reload.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use serde_json::json;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::consts::GRID_CELL_SIZE;
|
||||||
|
use crate::data::{self, PropertyData};
|
||||||
|
use crate::metrics::record_data_stats;
|
||||||
|
use crate::routes::{build_features_response, build_system_prompt};
|
||||||
|
use crate::state::{AppState, SharedState};
|
||||||
|
use crate::utils::GridIndex;
|
||||||
|
|
||||||
|
pub async fn post_reload(shared: Arc<SharedState>) -> Response {
|
||||||
|
if !shared.try_start_reload() {
|
||||||
|
return (StatusCode::CONFLICT, "Reload already in progress").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Reload triggered — rebuilding property data");
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// shared is cloned so we retain a reference after spawn_blocking
|
||||||
|
let sh = Arc::clone(&shared);
|
||||||
|
let result = tokio::task::spawn_blocking(move || rebuild_data(&sh, start)).await;
|
||||||
|
|
||||||
|
// Always clear the reload flag
|
||||||
|
shared.finish_reload();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok((rows, features, elapsed_ms))) => Json(json!({
|
||||||
|
"status": "ok",
|
||||||
|
"rows": rows,
|
||||||
|
"features": features,
|
||||||
|
"elapsed_ms": elapsed_ms,
|
||||||
|
}))
|
||||||
|
.into_response(),
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
warn!("Reload failed: {err:#}");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": format!("{err:#}") })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Reload task panicked: {err}");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": format!("Reload task panicked: {err}") })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_data(shared: &SharedState, start: Instant) -> anyhow::Result<(usize, usize, u128)> {
|
||||||
|
let old = shared.load_state();
|
||||||
|
|
||||||
|
// 1. Load PropertyData from parquet files
|
||||||
|
let property_data = PropertyData::load(
|
||||||
|
&shared.properties_path,
|
||||||
|
&shared.postcode_features_path,
|
||||||
|
&shared.listings_buy_path,
|
||||||
|
&shared.listings_rent_path,
|
||||||
|
)?;
|
||||||
|
let row_count = property_data.lat.len();
|
||||||
|
let feature_count = property_data.num_features;
|
||||||
|
|
||||||
|
// 2. Build spatial grid index
|
||||||
|
info!("Reload: building spatial grid index");
|
||||||
|
let grid = GridIndex::build(&property_data.lat, &property_data.lon, GRID_CELL_SIZE);
|
||||||
|
|
||||||
|
// 3. Precompute H3 cells
|
||||||
|
info!("Reload: precomputing H3 cells");
|
||||||
|
let h3_cells = data::precompute_h3(&property_data.lat, &property_data.lon)?;
|
||||||
|
|
||||||
|
// 4. Build feature lookup tables
|
||||||
|
let feature_name_to_index = property_data
|
||||||
|
.feature_names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, name)| (name.clone(), idx))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let min_keys = property_data
|
||||||
|
.feature_names
|
||||||
|
.iter()
|
||||||
|
.map(|n| format!("min_{n}"))
|
||||||
|
.collect();
|
||||||
|
let max_keys = property_data
|
||||||
|
.feature_names
|
||||||
|
.iter()
|
||||||
|
.map(|n| format!("max_{n}"))
|
||||||
|
.collect();
|
||||||
|
let avg_keys = property_data
|
||||||
|
.feature_names
|
||||||
|
.iter()
|
||||||
|
.map(|n| format!("avg_{n}"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 5. Build features response and AI prompt
|
||||||
|
let features_response = build_features_response(&property_data);
|
||||||
|
let mode_destinations: Vec<(String, usize)> = old
|
||||||
|
.travel_time_store
|
||||||
|
.available_modes
|
||||||
|
.iter()
|
||||||
|
.map(|mode| {
|
||||||
|
let count = old
|
||||||
|
.travel_time_store
|
||||||
|
.destinations
|
||||||
|
.get(mode.as_str())
|
||||||
|
.map(|slugs| slugs.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
(mode.clone(), count)
|
||||||
|
})
|
||||||
|
.filter(|(_, count)| *count > 0)
|
||||||
|
.collect();
|
||||||
|
let ai_filters_system_prompt = build_system_prompt(&features_response, &mode_destinations);
|
||||||
|
|
||||||
|
// 6. Update data metrics
|
||||||
|
record_data_stats(
|
||||||
|
row_count,
|
||||||
|
old.poi_data.lat.len(),
|
||||||
|
old.postcode_data.postcodes.len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Build new AppState, sharing unchanged fields via Arc
|
||||||
|
let new_state = AppState {
|
||||||
|
data: property_data,
|
||||||
|
grid,
|
||||||
|
h3_cells,
|
||||||
|
feature_name_to_index,
|
||||||
|
min_keys,
|
||||||
|
max_keys,
|
||||||
|
avg_keys,
|
||||||
|
features_response,
|
||||||
|
ai_filters_system_prompt,
|
||||||
|
|
||||||
|
// Shared across reloads (Arc clone is cheap)
|
||||||
|
poi_data: Arc::clone(&old.poi_data),
|
||||||
|
poi_grid: Arc::clone(&old.poi_grid),
|
||||||
|
place_data: Arc::clone(&old.place_data),
|
||||||
|
postcode_data: Arc::clone(&old.postcode_data),
|
||||||
|
poi_category_groups: Arc::clone(&old.poi_category_groups),
|
||||||
|
travel_time_store: Arc::clone(&old.travel_time_store),
|
||||||
|
token_cache: Arc::clone(&old.token_cache),
|
||||||
|
|
||||||
|
// Config (cheap clone)
|
||||||
|
screenshot_url: old.screenshot_url.clone(),
|
||||||
|
public_url: old.public_url.clone(),
|
||||||
|
is_dev: old.is_dev,
|
||||||
|
index_html: old.index_html.clone(),
|
||||||
|
http_client: old.http_client.clone(),
|
||||||
|
pocketbase_url: old.pocketbase_url.clone(),
|
||||||
|
pocketbase_admin_email: old.pocketbase_admin_email.clone(),
|
||||||
|
pocketbase_admin_password: old.pocketbase_admin_password.clone(),
|
||||||
|
gemini_api_key: old.gemini_api_key.clone(),
|
||||||
|
gemini_model: old.gemini_model.clone(),
|
||||||
|
google_maps_api_key: old.google_maps_api_key.clone(),
|
||||||
|
stripe_secret_key: old.stripe_secret_key.clone(),
|
||||||
|
stripe_webhook_secret: old.stripe_webhook_secret.clone(),
|
||||||
|
stripe_referral_coupon_id: old.stripe_referral_coupon_id.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 8. Atomic swap
|
||||||
|
shared.swap_state(new_state);
|
||||||
|
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
info!(
|
||||||
|
rows = row_count,
|
||||||
|
features = feature_count,
|
||||||
|
elapsed_ms = elapsed.as_millis(),
|
||||||
|
"Reload complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((row_count, feature_count, elapsed.as_millis()))
|
||||||
|
}
|
||||||
|
|
@ -93,4 +93,18 @@ impl TravelTimeAgg {
|
||||||
self.sum += value as f64;
|
self.sum += value as f64;
|
||||||
self.count += 1;
|
self.count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merge another aggregator's results into this one (for parallel reduction).
|
||||||
|
pub fn merge(&mut self, other: &TravelTimeAgg) {
|
||||||
|
if other.count > 0 {
|
||||||
|
if other.min < self.min {
|
||||||
|
self.min = other.min;
|
||||||
|
}
|
||||||
|
if other.max > self.max {
|
||||||
|
self.max = other.max;
|
||||||
|
}
|
||||||
|
self.sum += other.sum;
|
||||||
|
self.count += other.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use parking_lot::RwLock;
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
|
|
||||||
use crate::auth::TokenCache;
|
use crate::auth::TokenCache;
|
||||||
|
|
@ -10,16 +13,12 @@ use crate::routes::FeaturesResponse;
|
||||||
use crate::utils::GridIndex;
|
use crate::utils::GridIndex;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
|
// --- Rebuilt on reload ---
|
||||||
pub data: PropertyData,
|
pub data: PropertyData,
|
||||||
pub grid: GridIndex,
|
pub grid: GridIndex,
|
||||||
/// h3_cells[row_idx] = precomputed H3 cell ID at max resolution (12).
|
/// h3_cells[row_idx] = precomputed H3 cell ID at max resolution (12).
|
||||||
/// Parent cells for lower resolutions derived via CellIndex::parent().
|
/// Parent cells for lower resolutions derived via CellIndex::parent().
|
||||||
pub h3_cells: Vec<u64>,
|
pub h3_cells: Vec<u64>,
|
||||||
pub poi_data: POIData,
|
|
||||||
pub poi_grid: GridIndex,
|
|
||||||
pub place_data: PlaceData,
|
|
||||||
/// Postcode boundary data for high-zoom rendering
|
|
||||||
pub postcode_data: PostcodeData,
|
|
||||||
/// O(1) lookup: feature name → index in feature_names/feature_data
|
/// O(1) lookup: feature name → index in feature_names/feature_data
|
||||||
pub feature_name_to_index: FxHashMap<String, usize>,
|
pub feature_name_to_index: FxHashMap<String, usize>,
|
||||||
/// Precomputed JSON key names: "min_{feature_name}" for each feature
|
/// Precomputed JSON key names: "min_{feature_name}" for each feature
|
||||||
|
|
@ -28,10 +27,25 @@ pub struct AppState {
|
||||||
pub max_keys: Vec<String>,
|
pub max_keys: Vec<String>,
|
||||||
/// Precomputed JSON key names: "avg_{feature_name}" for each feature
|
/// Precomputed JSON key names: "avg_{feature_name}" for each feature
|
||||||
pub avg_keys: Vec<String>,
|
pub avg_keys: Vec<String>,
|
||||||
/// Precomputed POI category groups (sorted)
|
|
||||||
pub poi_category_groups: Vec<POICategoryGroup>,
|
|
||||||
/// Precomputed features response for /api/features endpoint
|
/// Precomputed features response for /api/features endpoint
|
||||||
pub features_response: FeaturesResponse,
|
pub features_response: FeaturesResponse,
|
||||||
|
/// Complete system prompt for AI filters (features + examples + instructions)
|
||||||
|
pub ai_filters_system_prompt: String,
|
||||||
|
|
||||||
|
// --- Shared across reloads (Arc for cheap cloning) ---
|
||||||
|
pub poi_data: Arc<POIData>,
|
||||||
|
pub poi_grid: Arc<GridIndex>,
|
||||||
|
pub place_data: Arc<PlaceData>,
|
||||||
|
/// Postcode boundary data for high-zoom rendering
|
||||||
|
pub postcode_data: Arc<PostcodeData>,
|
||||||
|
/// Precomputed POI category groups (sorted)
|
||||||
|
pub poi_category_groups: Arc<Vec<POICategoryGroup>>,
|
||||||
|
/// Precomputed travel time data store
|
||||||
|
pub travel_time_store: Arc<TravelTimeStore>,
|
||||||
|
/// Token validation cache (60s TTL)
|
||||||
|
pub token_cache: Arc<TokenCache>,
|
||||||
|
|
||||||
|
// --- Config (cheap to clone) ---
|
||||||
/// URL of the screenshot service (e.g. http://screenshot:8002)
|
/// URL of the screenshot service (e.g. http://screenshot:8002)
|
||||||
pub screenshot_url: String,
|
pub screenshot_url: String,
|
||||||
/// Public-facing URL for absolute og:image URLs (e.g. https://perfectpostcodes.dev)
|
/// Public-facing URL for absolute og:image URLs (e.g. https://perfectpostcodes.dev)
|
||||||
|
|
@ -52,12 +66,6 @@ pub struct AppState {
|
||||||
pub gemini_api_key: String,
|
pub gemini_api_key: String,
|
||||||
/// Gemini model name (e.g. gemini-2.0-flash)
|
/// Gemini model name (e.g. gemini-2.0-flash)
|
||||||
pub gemini_model: String,
|
pub gemini_model: String,
|
||||||
/// Precomputed travel time data store
|
|
||||||
pub travel_time_store: Arc<TravelTimeStore>,
|
|
||||||
/// Token validation cache (60s TTL)
|
|
||||||
pub token_cache: Arc<TokenCache>,
|
|
||||||
/// Complete system prompt for AI filters (features + examples + instructions)
|
|
||||||
pub ai_filters_system_prompt: String,
|
|
||||||
/// Google Maps API key for Street View metadata lookups
|
/// Google Maps API key for Street View metadata lookups
|
||||||
pub google_maps_api_key: String,
|
pub google_maps_api_key: String,
|
||||||
/// Stripe secret key for creating checkout sessions
|
/// Stripe secret key for creating checkout sessions
|
||||||
|
|
@ -67,3 +75,57 @@ pub struct AppState {
|
||||||
/// Stripe Coupon ID for referral discounts
|
/// Stripe Coupon ID for referral discounts
|
||||||
pub stripe_referral_coupon_id: String,
|
pub stripe_referral_coupon_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wraps AppState with atomic swap capability for hot-reloading.
|
||||||
|
/// Route handlers call `load_state()` to get the current snapshot.
|
||||||
|
/// The reload endpoint builds a new AppState and swaps it in atomically.
|
||||||
|
pub struct SharedState {
|
||||||
|
current: RwLock<Arc<AppState>>,
|
||||||
|
reloading: AtomicBool,
|
||||||
|
/// Paths needed for data reload
|
||||||
|
pub properties_path: PathBuf,
|
||||||
|
pub postcode_features_path: PathBuf,
|
||||||
|
pub listings_buy_path: PathBuf,
|
||||||
|
pub listings_rent_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedState {
|
||||||
|
pub fn new(
|
||||||
|
state: AppState,
|
||||||
|
properties_path: PathBuf,
|
||||||
|
postcode_features_path: PathBuf,
|
||||||
|
listings_buy_path: PathBuf,
|
||||||
|
listings_rent_path: PathBuf,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
current: RwLock::new(Arc::new(state)),
|
||||||
|
reloading: AtomicBool::new(false),
|
||||||
|
properties_path,
|
||||||
|
postcode_features_path,
|
||||||
|
listings_buy_path,
|
||||||
|
listings_rent_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current AppState snapshot. Cheap (Arc clone under a brief read lock).
|
||||||
|
pub fn load_state(&self) -> Arc<AppState> {
|
||||||
|
self.current.read().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically swap in a new AppState. Old state is dropped when all references are gone.
|
||||||
|
pub fn swap_state(&self, new_state: AppState) {
|
||||||
|
*self.current.write() = Arc::new(new_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to mark reload as in-progress. Returns false if already reloading.
|
||||||
|
pub fn try_start_reload(&self) -> bool {
|
||||||
|
self.reloading
|
||||||
|
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark reload as complete.
|
||||||
|
pub fn finish_reload(&self) {
|
||||||
|
self.reloading.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,26 @@ impl GridIndex {
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Count the number of row indices within the given bounds without allocating.
|
||||||
|
/// O(grid cells in bounds) — much cheaper than query() for threshold decisions.
|
||||||
|
pub fn count_in_bounds(&self, south: f64, west: f64, north: f64, east: f64) -> usize {
|
||||||
|
let Some((row_min, row_max, col_min, col_max)) =
|
||||||
|
self.clamp_bounds(south, west, north, east)
|
||||||
|
else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut count = 0usize;
|
||||||
|
for row in row_min..=row_max {
|
||||||
|
let row_start = row * self.cols;
|
||||||
|
for col in col_min..=col_max {
|
||||||
|
let cell_idx = row_start + col;
|
||||||
|
count += (self.offsets[cell_idx + 1] - self.offsets[cell_idx]) as usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn for_each_in_bounds(
|
pub fn for_each_in_bounds(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -334,4 +354,27 @@ mod tests {
|
||||||
let result = grid.query(-90.0, -180.0, 90.0, 180.0);
|
let result = grid.query(-90.0, -180.0, 90.0, 180.0);
|
||||||
assert_eq!(result.len(), 2);
|
assert_eq!(result.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn count_in_bounds_matches_query_len() {
|
||||||
|
let lat = vec![51.5_f32, 51.6, 51.7, 52.0];
|
||||||
|
let lon = vec![-0.1_f32, -0.1, -0.1, -0.1];
|
||||||
|
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||||
|
|
||||||
|
let bounds = (51.4, -0.2, 51.8, 0.0);
|
||||||
|
assert_eq!(
|
||||||
|
grid.count_in_bounds(bounds.0, bounds.1, bounds.2, bounds.3),
|
||||||
|
grid.query(bounds.0, bounds.1, bounds.2, bounds.3).len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Full bounds
|
||||||
|
let full = (50.0, -1.0, 53.0, 1.0);
|
||||||
|
assert_eq!(
|
||||||
|
grid.count_in_bounds(full.0, full.1, full.2, full.3),
|
||||||
|
grid.query(full.0, full.1, full.2, full.3).len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Empty bounds
|
||||||
|
assert_eq!(grid.count_in_bounds(0.0, 0.0, 1.0, 1.0), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue