Remove finder
This commit is contained in:
parent
55238f59aa
commit
cd778dd088
26 changed files with 0 additions and 57826 deletions
|
|
@ -1,16 +0,0 @@
|
||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY finder/pyproject.toml ./
|
|
||||||
RUN uv pip install --system -r pyproject.toml
|
|
||||||
RUN playwright install-deps chromium firefox
|
|
||||||
RUN playwright install chromium
|
|
||||||
RUN camoufox fetch \
|
|
||||||
&& python -c "from camoufox.pkgman import camoufox_path; p = camoufox_path(download_if_missing=False); print('Camoufox verified at', p)"
|
|
||||||
|
|
||||||
COPY finder/*.py ./
|
|
||||||
COPY property-data/arcgis_data.parquet /data/arcgis_data.parquet
|
|
||||||
|
|
||||||
CMD ["python3", "main.py"]
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY pyproject.toml ./
|
|
||||||
RUN uv pip install --system -r pyproject.toml
|
|
||||||
RUN playwright install-deps firefox
|
|
||||||
RUN camoufox fetch \
|
|
||||||
&& python -c "from camoufox.pkgman import camoufox_path; p = camoufox_path(download_if_missing=False); print('Camoufox verified at', p)"
|
|
||||||
|
|
||||||
COPY *.py ./
|
|
||||||
COPY property-data/arcgis_data.parquet /data/arcgis_data.parquet
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:1234/health')"
|
|
||||||
|
|
||||||
CMD ["python3", "main.py"]
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ARCGIS_PATH = os.environ.get("ARCGIS_PATH", "/data/arcgis_data.parquet")
|
|
||||||
DATA_DIR = Path("/app/data")
|
|
||||||
PAGE_SIZE = 24
|
|
||||||
DELAY_BETWEEN_PAGES = 0.3
|
|
||||||
DELAY_BETWEEN_OUTCODES = 0.5
|
|
||||||
MAX_RETRIES = 3
|
|
||||||
RETRY_BASE_DELAY = 2.0
|
|
||||||
GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index
|
|
||||||
MAX_BEDROOMS = 20 # sanity cap — values above this are almost certainly parsing errors
|
|
||||||
# Rent sanity bounds (monthly). Rents outside this range are nulled out — they are
|
|
||||||
# almost always total-stay pricing (e.g. "Golf Open 2026" short lets), annual rents
|
|
||||||
# mislabelled as monthly, or data errors.
|
|
||||||
MIN_RENT_MONTHLY = 50 # below £50/month is implausible for any UK property
|
|
||||||
MAX_RENT_MONTHLY = 25_000 # above £25k/month covers ultra-prime London; higher is suspect
|
|
||||||
SEED = 42
|
|
||||||
CHECKPOINT_INTERVAL = int(os.environ.get("CHECKPOINT_INTERVAL", "900")) # seconds
|
|
||||||
|
|
||||||
# Schedule: hour of day (UTC) to auto-run scrape. Set to -1 to disable.
|
|
||||||
SCHEDULE_HOUR = int(os.environ.get("SCHEDULE_HOUR", "3"))
|
|
||||||
# Whether to run a scrape immediately on startup
|
|
||||||
RUN_ON_STARTUP = os.environ.get("RUN_ON_STARTUP", "").lower() in ("1", "true", "yes")
|
|
||||||
# Enable/disable individual sources
|
|
||||||
SCRAPE_RIGHTMOVE = os.environ.get("SCRAPE_RIGHTMOVE", "true").lower() in (
|
|
||||||
"1",
|
|
||||||
"true",
|
|
||||||
"yes",
|
|
||||||
)
|
|
||||||
SCRAPE_HOMECOUK = os.environ.get("SCRAPE_HOMECOUK", "true").lower() in (
|
|
||||||
"1",
|
|
||||||
"true",
|
|
||||||
"yes",
|
|
||||||
)
|
|
||||||
SCRAPE_OPENRENT = os.environ.get("SCRAPE_OPENRENT", "true").lower() in (
|
|
||||||
"1",
|
|
||||||
"true",
|
|
||||||
"yes",
|
|
||||||
)
|
|
||||||
SCRAPE_ZOOPLA = os.environ.get("SCRAPE_ZOOPLA", "true").lower() in (
|
|
||||||
"1",
|
|
||||||
"true",
|
|
||||||
"yes",
|
|
||||||
)
|
|
||||||
|
|
||||||
# URL to trigger server data reload after scrape (e.g. http://server:8001/api/reload)
|
|
||||||
RELOAD_URL = os.environ.get("RELOAD_URL", "")
|
|
||||||
|
|
||||||
TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
|
|
||||||
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
|
|
||||||
RIGHTMOVE_BASE = "https://www.rightmove.co.uk"
|
|
||||||
|
|
||||||
# home.co.uk
|
|
||||||
HOMECOUK_BASE = "https://home.co.uk"
|
|
||||||
HOMECOUK_API_BASE = f"{HOMECOUK_BASE}/api"
|
|
||||||
HOMECOUK_PER_PAGE = 30 # max supported by the API
|
|
||||||
HOMECOUK_CONCURRENCY = int(os.environ.get("HOMECOUK_CONCURRENCY", "4"))
|
|
||||||
|
|
||||||
# OpenRent
|
|
||||||
OPENRENT_BASE = "https://www.openrent.co.uk"
|
|
||||||
|
|
||||||
# Zoopla
|
|
||||||
ZOOPLA_BASE = "https://www.zoopla.co.uk"
|
|
||||||
|
|
||||||
PROPERTY_TYPE_MAP = {
|
|
||||||
"Detached": "Detached",
|
|
||||||
"Semi-Detached": "Semi-Detached",
|
|
||||||
"Terraced": "Terraced",
|
|
||||||
"End of Terrace": "Terraced",
|
|
||||||
"Mid Terrace": "Terraced",
|
|
||||||
"Flat": "Flats/Maisonettes",
|
|
||||||
"Maisonette": "Flats/Maisonettes",
|
|
||||||
"Studio": "Flats/Maisonettes",
|
|
||||||
"Apartment": "Flats/Maisonettes",
|
|
||||||
"Penthouse": "Flats/Maisonettes",
|
|
||||||
"Ground Flat": "Flats/Maisonettes",
|
|
||||||
"Duplex": "Flats/Maisonettes",
|
|
||||||
"Detached Bungalow": "Detached",
|
|
||||||
"Semi-Detached Bungalow": "Semi-Detached",
|
|
||||||
"Town House": "Terraced",
|
|
||||||
"Link Detached": "Detached",
|
|
||||||
"Link Detached House": "Detached",
|
|
||||||
"Bungalow": "Other",
|
|
||||||
"Cottage": "Other",
|
|
||||||
"Park Home": "Other",
|
|
||||||
"Mobile Home": "Other",
|
|
||||||
"Caravan": "Other",
|
|
||||||
"Lodge": "Other",
|
|
||||||
"Land": "Other",
|
|
||||||
"Farm / Barn": "Other",
|
|
||||||
"Farm House": "Other",
|
|
||||||
"House": "Detached",
|
|
||||||
"House of Multiple Occupation": "Flats/Maisonettes",
|
|
||||||
"House Share": "Other",
|
|
||||||
"Not Specified": "Other",
|
|
||||||
"Chalet": "Other",
|
|
||||||
"Barn Conversion": "Other",
|
|
||||||
"Coach House": "Other",
|
|
||||||
"Character Property": "Other",
|
|
||||||
"Cluster House": "Other",
|
|
||||||
"Retirement Property": "Flats/Maisonettes",
|
|
||||||
"Parking": "Other",
|
|
||||||
"Plot": "Other",
|
|
||||||
"Garages": "Other",
|
|
||||||
"Mews": "Terraced",
|
|
||||||
"Property": "Other",
|
|
||||||
"Flat Share": "Other",
|
|
||||||
"Block of Apartments": "Flats/Maisonettes",
|
|
||||||
"Private Halls": "Flats/Maisonettes",
|
|
||||||
"Terraced Bungalow": "Terraced",
|
|
||||||
"Equestrian Facility": "Other",
|
|
||||||
"Ground Maisonette": "Flats/Maisonettes",
|
|
||||||
"Country House": "Detached",
|
|
||||||
"Village House": "Detached",
|
|
||||||
"Farm Land": "Other",
|
|
||||||
"House Boat": "Other",
|
|
||||||
"Barn": "Other",
|
|
||||||
"Serviced Apartments": "Flats/Maisonettes",
|
|
||||||
# Space-separated variants (from home.co.uk underscore/hyphen normalization)
|
|
||||||
"Semi Detached": "Semi-Detached",
|
|
||||||
"Semi Detached Bungalow": "Semi-Detached",
|
|
||||||
"End Of Terrace": "Terraced",
|
|
||||||
"End Terrace": "Terraced",
|
|
||||||
"Block Of Apartments": "Flats/Maisonettes",
|
|
||||||
"Farm / Barn": "Other",
|
|
||||||
# Lowercase variants (from home.co.uk / Rightmove APIs)
|
|
||||||
"house": "Detached",
|
|
||||||
"bungalow": "Other",
|
|
||||||
"townhouse": "Terraced",
|
|
||||||
"land": "Other",
|
|
||||||
"other": "Other",
|
|
||||||
"not-specified": "Other",
|
|
||||||
"retirement-property": "Flats/Maisonettes",
|
|
||||||
"equestrian-facility": "Other",
|
|
||||||
"flat": "Flats/Maisonettes",
|
|
||||||
"detached": "Detached",
|
|
||||||
"semi-detached": "Semi-Detached",
|
|
||||||
"terraced": "Terraced",
|
|
||||||
"maisonette": "Flats/Maisonettes",
|
|
||||||
"apartment": "Flats/Maisonettes",
|
|
||||||
"studio": "Flats/Maisonettes",
|
|
||||||
"penthouse": "Flats/Maisonettes",
|
|
||||||
"cottage": "Other",
|
|
||||||
"chalet": "Other",
|
|
||||||
"farm_house": "Detached",
|
|
||||||
"country house": "Detached",
|
|
||||||
"village house": "Detached",
|
|
||||||
}
|
|
||||||
|
|
||||||
CHANNELS = [
|
|
||||||
{"channel": "BUY", "transactionType": "BUY", "sortType": "2"},
|
|
||||||
{"channel": "RENT", "transactionType": "LETTING", "sortType": "6"},
|
|
||||||
]
|
|
||||||
File diff suppressed because it is too large
Load diff
Binary file not shown.
Binary file not shown.
|
|
@ -1,429 +0,0 @@
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from curl_cffi.requests import Session
|
|
||||||
from curl_cffi.requests.errors import RequestsError
|
|
||||||
|
|
||||||
from constants import (
|
|
||||||
DELAY_BETWEEN_PAGES,
|
|
||||||
HOMECOUK_API_BASE,
|
|
||||||
HOMECOUK_BASE,
|
|
||||||
HOMECOUK_PER_PAGE,
|
|
||||||
MAX_BEDROOMS,
|
|
||||||
PROPERTY_TYPE_MAP,
|
|
||||||
RETRY_BASE_DELAY,
|
|
||||||
)
|
|
||||||
from metrics import (
|
|
||||||
flaresolverr_attempts_total,
|
|
||||||
homecouk_errors_total,
|
|
||||||
homecouk_properties_scraped,
|
|
||||||
homecouk_requests_total,
|
|
||||||
)
|
|
||||||
from spatial import PostcodeSpatialIndex
|
|
||||||
from transform import normalize_postcode, normalize_sub_type, validate_floor_area
|
|
||||||
|
|
||||||
log = logging.getLogger("homecouk")
|
|
||||||
|
|
||||||
|
|
||||||
class CookiesExpiredError(Exception):
|
|
||||||
"""Raised when home.co.uk returns 403, indicating cookies need refresh."""
|
|
||||||
|
|
||||||
|
|
||||||
# Channel mapping: internal name → URL path segment
|
|
||||||
HOMECOUK_CHANNELS = {
|
|
||||||
"BUY": "for-sale",
|
|
||||||
"RENT": "to-rent",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
FLARESOLVERR_URL = os.environ.get("FLARESOLVERR_URL", "http://flaresolverr:8191")
|
|
||||||
|
|
||||||
|
|
||||||
def solve_cloudflare() -> tuple[dict[str, str], str] | None:
|
|
||||||
"""Use FlareSolverr to solve the Cloudflare challenge.
|
|
||||||
Returns (cookies_dict, user_agent) or None on failure."""
|
|
||||||
log.info("Solving Cloudflare challenge via FlareSolverr at %s", FLARESOLVERR_URL)
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=120) as client:
|
|
||||||
resp = client.post(
|
|
||||||
f"{FLARESOLVERR_URL}/v1",
|
|
||||||
json={
|
|
||||||
"cmd": "request.get",
|
|
||||||
"url": f"{HOMECOUK_BASE}/for-sale/e1/",
|
|
||||||
"maxTimeout": 60000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
log.error("FlareSolverr returned HTTP %d", resp.status_code)
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
if data.get("status") != "ok":
|
|
||||||
log.error("FlareSolverr error: %s", data.get("message", "unknown"))
|
|
||||||
return None
|
|
||||||
|
|
||||||
solution = data["solution"]
|
|
||||||
raw_cookies = solution.get("cookies", [])
|
|
||||||
user_agent = solution.get("userAgent", "")
|
|
||||||
|
|
||||||
# Pass through ALL cookies from FlareSolverr — different Cloudflare
|
|
||||||
# configurations set different cookies (cf_clearance only appears when
|
|
||||||
# a challenge is triggered; it's not needed if no challenge was detected)
|
|
||||||
cookies = {}
|
|
||||||
for c in raw_cookies:
|
|
||||||
name = c.get("name", "")
|
|
||||||
if name:
|
|
||||||
cookies[name] = c["value"]
|
|
||||||
|
|
||||||
if not cookies:
|
|
||||||
log.error("FlareSolverr solved but returned no cookies at all")
|
|
||||||
flaresolverr_attempts_total.labels(result="no_cookies").inc()
|
|
||||||
return None
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Cloudflare solved — got %d cookies, UA: %s",
|
|
||||||
len(cookies),
|
|
||||||
user_agent[:60],
|
|
||||||
)
|
|
||||||
flaresolverr_attempts_total.labels(result="success").inc()
|
|
||||||
return cookies, user_agent
|
|
||||||
|
|
||||||
except (httpx.ConnectError, httpx.ReadTimeout) as e:
|
|
||||||
log.warning("FlareSolverr not available: %s", e)
|
|
||||||
flaresolverr_attempts_total.labels(result="unavailable").inc()
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
log.error("FlareSolverr error: %s", e)
|
|
||||||
flaresolverr_attempts_total.labels(result="error").inc()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_cookies() -> tuple[dict[str, str], str] | None:
|
|
||||||
"""Get home.co.uk cookies + user-agent.
|
|
||||||
Tries FlareSolverr first, then falls back to environment variables.
|
|
||||||
Returns (cookies_dict, user_agent) or None if not configured."""
|
|
||||||
# Try FlareSolverr first
|
|
||||||
result = solve_cloudflare()
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Fall back to env vars
|
|
||||||
cf_clearance = os.environ.get("HOMECOUK_CF_CLEARANCE", "")
|
|
||||||
session = os.environ.get("HOMECOUK_SESSION", "")
|
|
||||||
if not cf_clearance or not session:
|
|
||||||
return None
|
|
||||||
user_agent = os.environ.get(
|
|
||||||
"HOMECOUK_USER_AGENT",
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/145.0.0.0 Safari/537.36",
|
|
||||||
)
|
|
||||||
return {"cf_clearance": cf_clearance, "homecouk_session": session}, user_agent
|
|
||||||
|
|
||||||
|
|
||||||
def make_client(cookies: dict[str, str], user_agent: str) -> Session:
|
|
||||||
"""Create a curl_cffi Session configured for home.co.uk API calls.
|
|
||||||
Uses Chrome TLS impersonation so cf_clearance cookies (which are bound
|
|
||||||
to Chrome's JA3 fingerprint from FlareSolverr) remain valid."""
|
|
||||||
session = Session(impersonate="chrome")
|
|
||||||
session.headers.update(
|
|
||||||
{
|
|
||||||
"User-Agent": user_agent,
|
|
||||||
"Accept": "application/json, text/plain, */*",
|
|
||||||
"x-requested-with": "XMLHttpRequest",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
# Laravel CSRF: the XSRF-TOKEN cookie value must also be sent as the
|
|
||||||
# X-XSRF-TOKEN request header (URL-decoded). Without this header, the
|
|
||||||
# server rejects every request with 419/403.
|
|
||||||
xsrf = cookies.get("XSRF-TOKEN")
|
|
||||||
if xsrf:
|
|
||||||
session.headers["X-XSRF-TOKEN"] = unquote(xsrf)
|
|
||||||
for name, value in cookies.items():
|
|
||||||
session.cookies.set(name, value, domain="home.co.uk")
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def _status_label(code: int) -> str:
|
|
||||||
if code >= 500:
|
|
||||||
return "5xx"
|
|
||||||
return str(code)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_page(
|
|
||||||
client: Session, url: str, params: dict, max_retries: int = 3
|
|
||||||
) -> dict | None:
|
|
||||||
"""GET JSON with retries on 429/5xx. Returns None on permanent failure.
|
|
||||||
403 means cookies expired — raises CookiesExpiredError immediately."""
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
resp = client.get(url, params=params, timeout=30)
|
|
||||||
homecouk_requests_total.labels(status=_status_label(resp.status_code)).inc()
|
|
||||||
if resp.status_code == 200:
|
|
||||||
try:
|
|
||||||
return resp.json()
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
homecouk_errors_total.labels(type="json_decode").inc()
|
|
||||||
log.error(
|
|
||||||
"Non-JSON response from %s (got %s)",
|
|
||||||
url,
|
|
||||||
resp.headers.get("content-type", "?"),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if resp.status_code == 403:
|
|
||||||
raise CookiesExpiredError("HTTP 403 — cookies likely expired")
|
|
||||||
if resp.status_code in (429, 500, 502, 503, 504):
|
|
||||||
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
|
|
||||||
log.warning(
|
|
||||||
"HTTP %d from %s, retry %d/%d in %.1fs",
|
|
||||||
resp.status_code,
|
|
||||||
url,
|
|
||||||
attempt + 1,
|
|
||||||
max_retries,
|
|
||||||
delay,
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
continue
|
|
||||||
log.error("HTTP %d from %s (non-retryable)", resp.status_code, url)
|
|
||||||
return None
|
|
||||||
except CookiesExpiredError:
|
|
||||||
raise
|
|
||||||
except RequestsError as e:
|
|
||||||
homecouk_errors_total.labels(type=type(e).__name__).inc()
|
|
||||||
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
|
|
||||||
log.warning(
|
|
||||||
"%s from %s, retry %d/%d in %.1fs",
|
|
||||||
type(e).__name__,
|
|
||||||
url,
|
|
||||||
attempt + 1,
|
|
||||||
max_retries,
|
|
||||||
delay,
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
homecouk_errors_total.labels(type="retry_exhausted").inc()
|
|
||||||
log.error("All %d retries exhausted for %s", max_retries, url)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_floor_area(description: str | None) -> float | None:
|
|
||||||
"""Try to extract floor area from description text like '789 sq.ft.' or '73 sq.m.'."""
|
|
||||||
if not description:
|
|
||||||
return None
|
|
||||||
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*ft", description, re.IGNORECASE)
|
|
||||||
if m:
|
|
||||||
sqft = float(m.group(1).replace(",", ""))
|
|
||||||
return validate_floor_area(round(sqft * 0.092903, 1))
|
|
||||||
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*m", description, re.IGNORECASE)
|
|
||||||
if m:
|
|
||||||
return validate_floor_area(round(float(m.group(1).replace(",", "")), 1))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_tenure(prop: dict) -> str | None:
|
|
||||||
"""Extract tenure from home.co.uk property data.
|
|
||||||
|
|
||||||
Checks multiple sources in priority order:
|
|
||||||
1. Dedicated 'tenure' or 'tenure_type' field in the API response
|
|
||||||
2. Free-text search in the description for 'freehold' / 'leasehold'
|
|
||||||
3. Free-text search in features lists
|
|
||||||
|
|
||||||
home.co.uk aggregates listings from estate agents, so tenure is often
|
|
||||||
embedded in the description text rather than a structured field.
|
|
||||||
"""
|
|
||||||
# 1. Check dedicated tenure fields (in case the API adds them)
|
|
||||||
for key in ("tenure", "tenure_type", "tenureType"):
|
|
||||||
val = prop.get(key)
|
|
||||||
if val and isinstance(val, str):
|
|
||||||
lower = val.lower().strip()
|
|
||||||
if "leasehold" in lower:
|
|
||||||
return "Leasehold"
|
|
||||||
if "freehold" in lower:
|
|
||||||
return "Freehold"
|
|
||||||
|
|
||||||
# 2. Check description text — estate agents often include tenure here
|
|
||||||
description = prop.get("description") or ""
|
|
||||||
if description:
|
|
||||||
lower_desc = description.lower()
|
|
||||||
if re.search(r"\bleasehold\b", lower_desc):
|
|
||||||
return "Leasehold"
|
|
||||||
if re.search(r"\bfreehold\b", lower_desc):
|
|
||||||
# Matches "Freehold" and "Share of Freehold" (both = freehold ownership)
|
|
||||||
return "Freehold"
|
|
||||||
|
|
||||||
# 3. Check features / key_features lists if present
|
|
||||||
for key in ("features", "key_features", "keyFeatures"):
|
|
||||||
features = prop.get(key)
|
|
||||||
if features and isinstance(features, list):
|
|
||||||
for feat in features:
|
|
||||||
if not isinstance(feat, str):
|
|
||||||
continue
|
|
||||||
lower_feat = feat.lower()
|
|
||||||
if "leasehold" in lower_feat:
|
|
||||||
return "Leasehold"
|
|
||||||
if "freehold" in lower_feat:
|
|
||||||
return "Freehold"
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def map_property_type(raw_type: str | None) -> str:
|
|
||||||
"""Map home.co.uk property type to canonical type."""
|
|
||||||
if not raw_type:
|
|
||||||
return "Other"
|
|
||||||
canonical = PROPERTY_TYPE_MAP.get(raw_type)
|
|
||||||
if canonical:
|
|
||||||
return canonical
|
|
||||||
# Home.co.uk uses types like "House", "Flat", "Apartment", "Detached", etc.
|
|
||||||
# Try common patterns
|
|
||||||
lower = raw_type.lower()
|
|
||||||
if (
|
|
||||||
"flat" in lower
|
|
||||||
or "apartment" in lower
|
|
||||||
or "maisonette" in lower
|
|
||||||
or "studio" in lower
|
|
||||||
):
|
|
||||||
return "Flats/Maisonettes"
|
|
||||||
if "detached" in lower and "semi" not in lower:
|
|
||||||
return "Detached"
|
|
||||||
if "semi" in lower:
|
|
||||||
return "Semi-Detached"
|
|
||||||
if "terrace" in lower or "mews" in lower:
|
|
||||||
return "Terraced"
|
|
||||||
log.debug("Unknown property type: %r — mapping to Other", raw_type)
|
|
||||||
return "Other"
|
|
||||||
|
|
||||||
|
|
||||||
def transform_property(
|
|
||||||
prop: dict,
|
|
||||||
channel: str,
|
|
||||||
pc_index: PostcodeSpatialIndex,
|
|
||||||
) -> dict | None:
|
|
||||||
"""Transform a raw home.co.uk property dict into our output schema."""
|
|
||||||
lat = prop.get("latitude")
|
|
||||||
lng = prop.get("longitude")
|
|
||||||
if lat is None or lng is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Validate coordinates are in England
|
|
||||||
if not (49 <= lat <= 56 and -7 <= lng <= 2):
|
|
||||||
log.debug("Coords outside England: lat=%.4f lng=%.4f — skipping", lat, lng)
|
|
||||||
return None
|
|
||||||
|
|
||||||
price = prop.get("price") or prop.get("latest_price")
|
|
||||||
if not price or int(price) <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Home.co.uk provides postcodes directly, but fall back to spatial index
|
|
||||||
postcode = prop.get("postcode")
|
|
||||||
if not postcode:
|
|
||||||
postcode = pc_index.nearest(lat, lng)
|
|
||||||
if not postcode:
|
|
||||||
log.debug("No postcode for property at %.4f, %.4f — skipping", lat, lng)
|
|
||||||
return None
|
|
||||||
|
|
||||||
raw_beds = prop.get("bedrooms", 0) or 0
|
|
||||||
raw_baths = prop.get("bathrooms", 0) or 0
|
|
||||||
bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0
|
|
||||||
bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0
|
|
||||||
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
|
|
||||||
log.warning(
|
|
||||||
"home.co.uk %s: implausible beds=%d baths=%d (capped to 0)",
|
|
||||||
prop.get("listing_id") or prop.get("property_id") or "?",
|
|
||||||
raw_beds, raw_baths,
|
|
||||||
)
|
|
||||||
|
|
||||||
listing_type = prop.get("listing_property_type") or prop.get("property_type") or ""
|
|
||||||
address = prop.get("display_address") or prop.get("address") or ""
|
|
||||||
|
|
||||||
# Derive price qualifier from reduction info
|
|
||||||
price_qualifier = ""
|
|
||||||
if prop.get("is_reduced"):
|
|
||||||
pct = prop.get("reduction_percent", 0)
|
|
||||||
if pct:
|
|
||||||
price_qualifier = f"Reduced by {pct}%"
|
|
||||||
else:
|
|
||||||
price_qualifier = "Reduced"
|
|
||||||
|
|
||||||
listing_id = prop.get("listing_id") or prop.get("property_id") or ""
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": f"hk_{listing_id}", # prefix to avoid collision with Rightmove IDs
|
|
||||||
"Bedrooms": bedrooms,
|
|
||||||
"Bathrooms": bathrooms,
|
|
||||||
"Number of bedrooms & living rooms": bedrooms + bathrooms,
|
|
||||||
"lon": lng,
|
|
||||||
"lat": lat,
|
|
||||||
"Postcode": normalize_postcode(postcode),
|
|
||||||
"Address per Property Register": address,
|
|
||||||
"Leasehold/Freehold": parse_tenure(prop),
|
|
||||||
"Property type": map_property_type(listing_type),
|
|
||||||
"Property sub-type": normalize_sub_type(listing_type),
|
|
||||||
"price": int(price),
|
|
||||||
"price_frequency": "" if channel == "BUY" else "monthly",
|
|
||||||
"Price qualifier": price_qualifier,
|
|
||||||
"Total floor area (sqm)": parse_floor_area(prop.get("description")),
|
|
||||||
"Listing URL": f"{HOMECOUK_BASE}/property/{listing_id}",
|
|
||||||
"Listing features": [], # not available from home.co.uk
|
|
||||||
"first_visible_date": prop.get("added_date") or "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def search_outcode(
|
|
||||||
client: Session,
|
|
||||||
outcode: str,
|
|
||||||
channel: str,
|
|
||||||
pc_index: PostcodeSpatialIndex,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Paginate through search results for one outcode+channel.
|
|
||||||
channel: "BUY" or "RENT".
|
|
||||||
Returns transformed properties."""
|
|
||||||
url_segment = HOMECOUK_CHANNELS[channel]
|
|
||||||
url = f"{HOMECOUK_API_BASE}/{url_segment}/{outcode.lower()}/"
|
|
||||||
properties = []
|
|
||||||
page = 1
|
|
||||||
|
|
||||||
while True:
|
|
||||||
params = {
|
|
||||||
"page": str(page),
|
|
||||||
"sort": "date_desc",
|
|
||||||
"per_page": str(HOMECOUK_PER_PAGE),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set referer to match the page URL pattern
|
|
||||||
client.headers["referer"] = (
|
|
||||||
f"https://home.co.uk/{url_segment}/{outcode.lower()}/"
|
|
||||||
f"?page={page}&sort=date_desc&per_page={HOMECOUK_PER_PAGE}"
|
|
||||||
)
|
|
||||||
|
|
||||||
data = fetch_page(client, url, params)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
|
|
||||||
raw_props = data.get("properties", [])
|
|
||||||
if not raw_props:
|
|
||||||
break
|
|
||||||
|
|
||||||
for prop in raw_props:
|
|
||||||
transformed = transform_property(prop, channel, pc_index)
|
|
||||||
if transformed:
|
|
||||||
properties.append(transformed)
|
|
||||||
homecouk_properties_scraped.labels(
|
|
||||||
channel="buy" if channel == "BUY" else "rent",
|
|
||||||
).inc()
|
|
||||||
|
|
||||||
# Check pagination
|
|
||||||
pagination = data.get("pagination", {})
|
|
||||||
last_page = pagination.get("last_page", 1)
|
|
||||||
if page >= last_page:
|
|
||||||
break
|
|
||||||
|
|
||||||
page += 1
|
|
||||||
time.sleep(DELAY_BETWEEN_PAGES)
|
|
||||||
|
|
||||||
return properties
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fake_useragent import UserAgent
|
|
||||||
|
|
||||||
from constants import MAX_RETRIES, RETRY_BASE_DELAY
|
|
||||||
from metrics import http_errors_total, http_requests_total, ip_rotations_total
|
|
||||||
|
|
||||||
log = logging.getLogger("rightmove")
|
|
||||||
|
|
||||||
_ua = UserAgent(
|
|
||||||
browsers=["Chrome", "Edge"], os=["Windows", "Mac OS X"], min_version=120.0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _endpoint_label(url: str) -> str:
|
|
||||||
if "typeahead" in url:
|
|
||||||
return "typeahead"
|
|
||||||
if "search" in url:
|
|
||||||
return "search"
|
|
||||||
return "other"
|
|
||||||
|
|
||||||
|
|
||||||
def _status_label(code: int) -> str:
|
|
||||||
if code >= 500:
|
|
||||||
return "5xx"
|
|
||||||
return str(code)
|
|
||||||
|
|
||||||
|
|
||||||
# Gluetun control API — runs on port 8000 inside the gluetun container.
|
|
||||||
# Since finder uses network_mode: service:gluetun, localhost IS gluetun.
|
|
||||||
GLUETUN_API = "http://127.0.0.1:8000"
|
|
||||||
_ip_rotate_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def rotate_ip() -> bool:
|
|
||||||
"""Ask gluetun to reconnect to a different VPN server, getting a new IP.
|
|
||||||
Returns True if the IP changed successfully."""
|
|
||||||
with _ip_rotate_lock:
|
|
||||||
log.info("Rotating VPN IP via gluetun...")
|
|
||||||
try:
|
|
||||||
# Get current IP
|
|
||||||
with httpx.Client(timeout=10) as ctl:
|
|
||||||
old_ip_resp = ctl.get(f"{GLUETUN_API}/v1/publicip/ip")
|
|
||||||
old_ip = (
|
|
||||||
old_ip_resp.json().get("public_ip", "unknown")
|
|
||||||
if old_ip_resp.status_code == 200
|
|
||||||
else "unknown"
|
|
||||||
)
|
|
||||||
log.info("Current IP: %s", old_ip)
|
|
||||||
|
|
||||||
# Trigger server change — PUT with empty JSON body picks a random server
|
|
||||||
resp = ctl.put(
|
|
||||||
f"{GLUETUN_API}/v1/vpn/status", json={"status": "stopped"}
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
log.error("Failed to stop VPN: %d %s", resp.status_code, resp.text)
|
|
||||||
return False
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
resp = ctl.put(
|
|
||||||
f"{GLUETUN_API}/v1/vpn/status", json={"status": "running"}
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
log.error("Failed to start VPN: %d %s", resp.status_code, resp.text)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Wait for reconnection
|
|
||||||
for _ in range(30):
|
|
||||||
time.sleep(2)
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=10) as ctl:
|
|
||||||
new_ip_resp = ctl.get(f"{GLUETUN_API}/v1/publicip/ip")
|
|
||||||
if new_ip_resp.status_code == 200:
|
|
||||||
new_ip = new_ip_resp.json().get("public_ip", "")
|
|
||||||
if new_ip and new_ip != old_ip:
|
|
||||||
log.info("IP rotated: %s → %s", old_ip, new_ip)
|
|
||||||
ip_rotations_total.labels(result="success").inc()
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
pass # VPN still reconnecting
|
|
||||||
|
|
||||||
log.warning("IP rotation timed out (may still be same IP)")
|
|
||||||
ip_rotations_total.labels(result="failure").inc()
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.error("IP rotation failed: %s", e)
|
|
||||||
ip_rotations_total.labels(result="failure").inc()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def make_client() -> httpx.Client:
|
|
||||||
return httpx.Client(
|
|
||||||
timeout=30,
|
|
||||||
headers={"User-Agent": _ua.random, "Accept": "application/json"},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_with_retry(
|
|
||||||
client: httpx.Client, url: str, params: dict | None = None, on_403: bool = True
|
|
||||||
) -> dict | None:
|
|
||||||
"""GET JSON with retries on 429/5xx/connection errors. Returns None on permanent failure.
|
|
||||||
On 403, triggers IP rotation and retries once."""
|
|
||||||
endpoint = _endpoint_label(url)
|
|
||||||
for attempt in range(MAX_RETRIES):
|
|
||||||
try:
|
|
||||||
resp = client.get(url, params=params)
|
|
||||||
http_requests_total.labels(
|
|
||||||
status=_status_label(resp.status_code), endpoint=endpoint
|
|
||||||
).inc()
|
|
||||||
if resp.status_code == 200:
|
|
||||||
return resp.json()
|
|
||||||
if resp.status_code == 403 and on_403:
|
|
||||||
log.warning("HTTP 403 — IP likely blocked, rotating...")
|
|
||||||
if rotate_ip():
|
|
||||||
# Retry once with new IP (but don't recurse on 403 again)
|
|
||||||
return fetch_with_retry(client, url, params, on_403=False)
|
|
||||||
log.error("IP rotation failed, giving up on %s", url)
|
|
||||||
return None
|
|
||||||
if resp.status_code in (429, 500, 502, 503, 504):
|
|
||||||
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
|
|
||||||
log.warning(
|
|
||||||
"HTTP %d from %s, retry %d/%d in %.1fs",
|
|
||||||
resp.status_code,
|
|
||||||
url,
|
|
||||||
attempt + 1,
|
|
||||||
MAX_RETRIES,
|
|
||||||
delay,
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
continue
|
|
||||||
log.error("HTTP %d from %s (non-retryable)", resp.status_code, url)
|
|
||||||
return None
|
|
||||||
except (
|
|
||||||
httpx.ConnectError,
|
|
||||||
httpx.ReadTimeout,
|
|
||||||
httpx.WriteTimeout,
|
|
||||||
httpx.PoolTimeout,
|
|
||||||
) as e:
|
|
||||||
http_errors_total.labels(type=type(e).__name__).inc()
|
|
||||||
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
|
|
||||||
log.warning(
|
|
||||||
"%s from %s, retry %d/%d in %.1fs",
|
|
||||||
type(e).__name__,
|
|
||||||
url,
|
|
||||||
attempt + 1,
|
|
||||||
MAX_RETRIES,
|
|
||||||
delay,
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
http_errors_total.labels(type="retry_exhausted").inc()
|
|
||||||
log.error("All %d retries exhausted for %s", MAX_RETRIES, url)
|
|
||||||
return None
|
|
||||||
211
finder/main.py
211
finder/main.py
|
|
@ -1,211 +0,0 @@
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, send_from_directory
|
|
||||||
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
|
|
||||||
|
|
||||||
from constants import (
|
|
||||||
DATA_DIR,
|
|
||||||
RUN_ON_STARTUP,
|
|
||||||
SCHEDULE_HOUR,
|
|
||||||
SCRAPE_HOMECOUK,
|
|
||||||
SCRAPE_OPENRENT,
|
|
||||||
SCRAPE_RIGHTMOVE,
|
|
||||||
SCRAPE_ZOOPLA,
|
|
||||||
)
|
|
||||||
from homecouk import load_cookies as load_homecouk_cookies
|
|
||||||
from openrent import load_cookies as load_openrent_cookies
|
|
||||||
from rightmove import outcode_cache
|
|
||||||
from scraper import (
|
|
||||||
_sync_gauges,
|
|
||||||
build_postcode_coords,
|
|
||||||
build_postcode_index,
|
|
||||||
load_outcodes,
|
|
||||||
run_scrape,
|
|
||||||
status,
|
|
||||||
status_lock,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Logging
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
LOG_DIR = Path("/app/data")
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG,
|
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
||||||
handlers=[
|
|
||||||
logging.StreamHandler(),
|
|
||||||
logging.FileHandler(LOG_DIR / "rightmove.log"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
log = logging.getLogger("rightmove")
|
|
||||||
log.setLevel(logging.DEBUG)
|
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
||||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
||||||
|
|
||||||
|
|
||||||
# Suppress noisy /metrics and /health request logs from werkzeug
|
|
||||||
class _NoiseFilter(logging.Filter):
|
|
||||||
def filter(self, record):
|
|
||||||
msg = record.getMessage()
|
|
||||||
return "GET /metrics" not in msg and "GET /health" not in msg
|
|
||||||
|
|
||||||
|
|
||||||
logging.getLogger("werkzeug").addFilter(_NoiseFilter())
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Startup: load data
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
log.info("Loading arcgis data...")
|
|
||||||
OUTCODES = load_outcodes()
|
|
||||||
PC_INDEX = build_postcode_index()
|
|
||||||
PC_COORDS = build_postcode_coords() if (SCRAPE_OPENRENT or SCRAPE_ZOOPLA) else None
|
|
||||||
log.info(
|
|
||||||
"Ready — %d outcodes, postcode index built (rightmove=%s, homecouk=%s, openrent=%s, zoopla=%s)",
|
|
||||||
len(OUTCODES),
|
|
||||||
SCRAPE_RIGHTMOVE,
|
|
||||||
SCRAPE_HOMECOUK,
|
|
||||||
SCRAPE_OPENRENT,
|
|
||||||
SCRAPE_ZOOPLA,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Scheduler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _start_scrape() -> bool:
|
|
||||||
"""Try to start a scrape. Returns True if started, False if already running."""
|
|
||||||
with status_lock:
|
|
||||||
if status.state == "running":
|
|
||||||
return False
|
|
||||||
status.state = "running"
|
|
||||||
thread = threading.Thread(
|
|
||||||
target=run_scrape, args=(OUTCODES, PC_INDEX, PC_COORDS), daemon=True
|
|
||||||
)
|
|
||||||
thread.start()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _seconds_until(hour: int) -> float:
|
|
||||||
"""Seconds from now until the next occurrence of `hour`:00 UTC."""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
target = now.replace(hour=hour, minute=0, second=0, microsecond=0)
|
|
||||||
if target <= now:
|
|
||||||
target += timedelta(days=1)
|
|
||||||
return (target - now).total_seconds()
|
|
||||||
|
|
||||||
|
|
||||||
def _scheduler_loop() -> None:
|
|
||||||
"""Background thread that triggers a daily scrape at SCHEDULE_HOUR UTC."""
|
|
||||||
log.info("Scheduler active — will run daily at %02d:00 UTC", SCHEDULE_HOUR)
|
|
||||||
while True:
|
|
||||||
wait = _seconds_until(SCHEDULE_HOUR)
|
|
||||||
log.info(
|
|
||||||
"Next scheduled scrape in %.0f seconds (%.1f hours)", wait, wait / 3600
|
|
||||||
)
|
|
||||||
time.sleep(wait)
|
|
||||||
log.info("Scheduled scrape triggered")
|
|
||||||
if not _start_scrape():
|
|
||||||
log.warning("Scheduled scrape skipped — already running")
|
|
||||||
|
|
||||||
|
|
||||||
if RUN_ON_STARTUP:
|
|
||||||
log.info("RUN_ON_STARTUP=true — starting initial scrape")
|
|
||||||
_start_scrape()
|
|
||||||
|
|
||||||
if SCHEDULE_HOUR >= 0:
|
|
||||||
scheduler = threading.Thread(target=_scheduler_loop, daemon=True)
|
|
||||||
scheduler.start()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Flask app
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/health")
|
|
||||||
def health():
|
|
||||||
return "ok", 200
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/run", methods=["POST"])
|
|
||||||
def trigger_run():
|
|
||||||
if _start_scrape():
|
|
||||||
return jsonify({"message": "Scrape started"}), 200
|
|
||||||
return jsonify({"error": "Scrape already running"}), 409
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/status")
|
|
||||||
def get_status():
|
|
||||||
with status_lock:
|
|
||||||
elapsed = 0.0
|
|
||||||
if status.started_at:
|
|
||||||
end = status.finished_at if status.finished_at else time.time()
|
|
||||||
elapsed = end - status.started_at
|
|
||||||
resp = {
|
|
||||||
"state": status.state,
|
|
||||||
"channel": status.channel,
|
|
||||||
"outcode": status.outcode,
|
|
||||||
"outcodes_done": status.outcodes_done,
|
|
||||||
"outcodes_total": status.outcodes_total,
|
|
||||||
"properties_buy": status.properties_buy,
|
|
||||||
"properties_rent": status.properties_rent,
|
|
||||||
"properties_by_source": {
|
|
||||||
"rightmove": status.rm_properties,
|
|
||||||
"homecouk": status.hk_properties,
|
|
||||||
"openrent": status.or_properties,
|
|
||||||
"zoopla": status.zp_properties,
|
|
||||||
},
|
|
||||||
"errors": status.errors[-20:], # last 20 errors
|
|
||||||
"elapsed_seconds": round(elapsed, 1),
|
|
||||||
}
|
|
||||||
if SCHEDULE_HOUR >= 0:
|
|
||||||
resp["next_scrape_in_seconds"] = round(_seconds_until(SCHEDULE_HOUR))
|
|
||||||
return jsonify(resp)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/debug")
|
|
||||||
def get_debug():
|
|
||||||
hk_cookies = load_homecouk_cookies() if SCRAPE_HOMECOUK else None
|
|
||||||
or_cookies = load_openrent_cookies() if SCRAPE_OPENRENT else None
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"outcode_cache_size": len(outcode_cache),
|
|
||||||
"outcode_cache_sample": dict(list(outcode_cache.items())[:20]),
|
|
||||||
"scrape_rightmove": SCRAPE_RIGHTMOVE,
|
|
||||||
"scrape_homecouk": SCRAPE_HOMECOUK,
|
|
||||||
"scrape_openrent": SCRAPE_OPENRENT,
|
|
||||||
"scrape_zoopla": SCRAPE_ZOOPLA,
|
|
||||||
"homecouk_cookies_available": hk_cookies is not None,
|
|
||||||
"openrent_cookies_available": or_cookies is not None,
|
|
||||||
"zoopla_note": "browser-based (Camoufox), no cookies needed",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/metrics")
|
|
||||||
def metrics():
|
|
||||||
with status_lock:
|
|
||||||
_sync_gauges()
|
|
||||||
return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/data/<filename>")
|
|
||||||
def serve_data(filename):
|
|
||||||
if not filename.endswith(".parquet"):
|
|
||||||
return jsonify({"error": "Only parquet files served"}), 400
|
|
||||||
return send_from_directory(DATA_DIR, filename)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(host="0.0.0.0", port=1234, debug=False)
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
from prometheus_client import Counter, Gauge
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Gauges — current scrape state, updated after each outcode
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
scrape_state = Gauge(
|
|
||||||
"scrape_state",
|
|
||||||
"Current scrape state as a labeled gauge (1 = active)",
|
|
||||||
["state"],
|
|
||||||
)
|
|
||||||
|
|
||||||
scrape_outcodes_done = Gauge(
|
|
||||||
"scrape_outcodes_done",
|
|
||||||
"Outcodes processed in current channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
scrape_outcodes_total = Gauge(
|
|
||||||
"scrape_outcodes_total",
|
|
||||||
"Total outcodes in current channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
scrape_properties_total = Gauge(
|
|
||||||
"scrape_properties_total",
|
|
||||||
"Properties found so far",
|
|
||||||
["channel", "source"],
|
|
||||||
)
|
|
||||||
|
|
||||||
scrape_elapsed_seconds = Gauge(
|
|
||||||
"scrape_elapsed_seconds",
|
|
||||||
"Seconds since scrape started",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Counters — Rightmove (monotonically increasing)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
http_requests_total = Counter(
|
|
||||||
"http_requests_total",
|
|
||||||
"HTTP requests made to Rightmove",
|
|
||||||
["status", "endpoint"],
|
|
||||||
)
|
|
||||||
|
|
||||||
http_errors_total = Counter(
|
|
||||||
"http_errors_total",
|
|
||||||
"Rightmove HTTP connection/timeout errors",
|
|
||||||
["type"],
|
|
||||||
)
|
|
||||||
|
|
||||||
ip_rotations_total = Counter(
|
|
||||||
"ip_rotations_total",
|
|
||||||
"VPN IP rotation attempts",
|
|
||||||
["result"],
|
|
||||||
)
|
|
||||||
|
|
||||||
scrape_errors_total = Counter(
|
|
||||||
"scrape_errors_total",
|
|
||||||
"Per-outcode scrape errors",
|
|
||||||
["source"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Counters — home.co.uk
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
homecouk_requests_total = Counter(
|
|
||||||
"homecouk_requests_total",
|
|
||||||
"HTTP requests made to home.co.uk API",
|
|
||||||
["status"],
|
|
||||||
)
|
|
||||||
|
|
||||||
homecouk_errors_total = Counter(
|
|
||||||
"homecouk_errors_total",
|
|
||||||
"home.co.uk HTTP connection/timeout errors",
|
|
||||||
["type"],
|
|
||||||
)
|
|
||||||
|
|
||||||
homecouk_properties_scraped = Counter(
|
|
||||||
"homecouk_properties_scraped",
|
|
||||||
"Properties scraped from home.co.uk (before dedup)",
|
|
||||||
["channel"],
|
|
||||||
)
|
|
||||||
|
|
||||||
cross_source_dedup_total = Counter(
|
|
||||||
"cross_source_dedup_total",
|
|
||||||
"Properties skipped because same property already found on another source",
|
|
||||||
["channel"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Counters — OpenRent
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
openrent_requests_total = Counter(
|
|
||||||
"openrent_requests_total",
|
|
||||||
"HTTP requests made to OpenRent",
|
|
||||||
["status"],
|
|
||||||
)
|
|
||||||
|
|
||||||
openrent_errors_total = Counter(
|
|
||||||
"openrent_errors_total",
|
|
||||||
"OpenRent HTTP connection/timeout errors",
|
|
||||||
["type"],
|
|
||||||
)
|
|
||||||
|
|
||||||
openrent_properties_scraped = Counter(
|
|
||||||
"openrent_properties_scraped",
|
|
||||||
"Properties scraped from OpenRent (before dedup)",
|
|
||||||
["channel"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Counters — Zoopla
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
zoopla_pages_scraped = Counter(
|
|
||||||
"zoopla_pages_scraped",
|
|
||||||
"Search result pages scraped from Zoopla",
|
|
||||||
["channel"],
|
|
||||||
)
|
|
||||||
|
|
||||||
zoopla_errors_total = Counter(
|
|
||||||
"zoopla_errors_total",
|
|
||||||
"Zoopla scraping errors",
|
|
||||||
["type"],
|
|
||||||
)
|
|
||||||
|
|
||||||
zoopla_properties_scraped = Counter(
|
|
||||||
"zoopla_properties_scraped",
|
|
||||||
"Properties scraped from Zoopla (before dedup)",
|
|
||||||
["channel"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Counters — FlareSolverr / cookie management
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
flaresolverr_attempts_total = Counter(
|
|
||||||
"flaresolverr_attempts_total",
|
|
||||||
"FlareSolverr Cloudflare challenge-solving attempts",
|
|
||||||
["result"],
|
|
||||||
)
|
|
||||||
|
|
||||||
cookie_refreshes_total = Counter(
|
|
||||||
"cookie_refreshes_total",
|
|
||||||
"home.co.uk cookie refresh attempts (triggered by 403)",
|
|
||||||
["result"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Gauges — home.co.uk state
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
homecouk_enabled = Gauge(
|
|
||||||
"homecouk_enabled",
|
|
||||||
"Whether home.co.uk scraping is currently active (1=yes, 0=no)",
|
|
||||||
)
|
|
||||||
|
|
||||||
openrent_enabled = Gauge(
|
|
||||||
"openrent_enabled",
|
|
||||||
"Whether OpenRent scraping is currently active (1=yes, 0=no)",
|
|
||||||
)
|
|
||||||
|
|
||||||
zoopla_enabled = Gauge(
|
|
||||||
"zoopla_enabled",
|
|
||||||
"Whether Zoopla scraping is currently active (1=yes, 0=no)",
|
|
||||||
)
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
Hit the following url with the outcode as the location-id and the page. So for E13, page 2 it's:
|
|
||||||
|
|
||||||
https://www.onthemarket.com/async/search/properties-v2/?search-type=for-sale&location-id=e13&page=2&view=map-list
|
|
||||||
|
|
||||||
and the response is in [[response.json]]
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,869 +0,0 @@
|
||||||
"""OpenRent (openrent.co.uk) scraper — rental properties only.
|
|
||||||
|
|
||||||
OpenRent is behind AWS WAF, so we use Playwright (headless Chromium) to solve
|
|
||||||
the challenge and get valid cookies. Then we use curl_cffi with Chrome TLS
|
|
||||||
impersonation to make requests with those cookies.
|
|
||||||
|
|
||||||
OpenRent is a rental-only platform, so this scraper only handles RENT channel.
|
|
||||||
|
|
||||||
HTML structure (as of 2026-03):
|
|
||||||
Search results page renders property cards as <a class="pli search-property-card">.
|
|
||||||
Each card contains:
|
|
||||||
- Monthly price in <div class="pim"> with <span class="text-primary">£X,XXX</span>
|
|
||||||
- Weekly price in <div class="piw"> (hidden by Alpine.js)
|
|
||||||
- Title in <div class="fw-medium text-primary fs-3">N Bed Type, Location, OUTCODE</div>
|
|
||||||
- Features in <ul> with <li> items like "1 Bed", "1 Bath", "Furnished"
|
|
||||||
- Listing ID in data-listing-id on the .or-swiper div
|
|
||||||
- Description snippet in <div class="line-clamp-2">
|
|
||||||
|
|
||||||
Detail page has:
|
|
||||||
- <h1> with property title including outcode
|
|
||||||
- <div id="map" data-lat="..." data-lng="..."> for coordinates
|
|
||||||
- Tables with deposit, rent, furnishing, tenant preferences
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from curl_cffi.requests import Session
|
|
||||||
from curl_cffi.requests.errors import RequestsError
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
from constants import (
|
|
||||||
DELAY_BETWEEN_PAGES,
|
|
||||||
MAX_BEDROOMS,
|
|
||||||
OPENRENT_BASE,
|
|
||||||
PROPERTY_TYPE_MAP,
|
|
||||||
RETRY_BASE_DELAY,
|
|
||||||
)
|
|
||||||
from metrics import (
|
|
||||||
flaresolverr_attempts_total,
|
|
||||||
openrent_errors_total,
|
|
||||||
openrent_properties_scraped,
|
|
||||||
openrent_requests_total,
|
|
||||||
)
|
|
||||||
from spatial import PostcodeSpatialIndex
|
|
||||||
from transform import normalize_postcode, normalize_sub_type, validate_floor_area
|
|
||||||
|
|
||||||
log = logging.getLogger("openrent")
|
|
||||||
|
|
||||||
|
|
||||||
class WafChallengeError(Exception):
|
|
||||||
"""Raised when OpenRent returns a WAF challenge, indicating cookies need refresh."""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cookie / session management via Playwright
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def solve_waf() -> tuple[dict[str, str], str] | None:
|
|
||||||
"""Use Playwright (headless Chromium) to solve the AWS WAF challenge.
|
|
||||||
Returns (cookies_dict, user_agent) or None on failure."""
|
|
||||||
log.info("Solving AWS WAF challenge via Playwright")
|
|
||||||
try:
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(
|
|
||||||
headless=True,
|
|
||||||
args=["--no-sandbox", "--disable-blink-features=AutomationControlled"],
|
|
||||||
)
|
|
||||||
context = browser.new_context()
|
|
||||||
page = context.new_page()
|
|
||||||
|
|
||||||
url = f"{OPENRENT_BASE}/properties-to-rent/?term=london&isLive=true"
|
|
||||||
log.info("Navigating to %s", url)
|
|
||||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
|
||||||
|
|
||||||
content = page.content()
|
|
||||||
if "AwsWafIntegration" in content:
|
|
||||||
log.info("Got WAF challenge page, waiting for resolution...")
|
|
||||||
page.wait_for_selector(
|
|
||||||
"a.pli, .pli, .search-property-card",
|
|
||||||
timeout=30000,
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_cookies = context.cookies()
|
|
||||||
user_agent = page.evaluate("navigator.userAgent")
|
|
||||||
browser.close()
|
|
||||||
|
|
||||||
cookies = {c["name"]: c["value"] for c in raw_cookies}
|
|
||||||
if "aws-waf-token" not in cookies:
|
|
||||||
log.error("Playwright solved page but no aws-waf-token cookie found")
|
|
||||||
flaresolverr_attempts_total.labels(result="no_cookies").inc()
|
|
||||||
return None
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"AWS WAF solved — got %d cookies, UA: %s",
|
|
||||||
len(cookies),
|
|
||||||
user_agent[:60],
|
|
||||||
)
|
|
||||||
flaresolverr_attempts_total.labels(result="success").inc()
|
|
||||||
return cookies, user_agent
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.error("Playwright WAF solve failed: %s", e)
|
|
||||||
flaresolverr_attempts_total.labels(result="error").inc()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_cookies() -> tuple[dict[str, str], str] | None:
|
|
||||||
"""Get OpenRent cookies + user-agent.
|
|
||||||
Tries Playwright first, then falls back to environment variables."""
|
|
||||||
result = solve_waf()
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Fall back to env vars
|
|
||||||
waf_token = os.environ.get("OPENRENT_WAF_TOKEN", "")
|
|
||||||
if not waf_token:
|
|
||||||
return None
|
|
||||||
|
|
||||||
user_agent = os.environ.get(
|
|
||||||
"OPENRENT_USER_AGENT",
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/145.0.0.0 Safari/537.36",
|
|
||||||
)
|
|
||||||
return {"aws-waf-token": waf_token}, user_agent
|
|
||||||
|
|
||||||
|
|
||||||
def make_client(cookies: dict[str, str], user_agent: str) -> Session:
|
|
||||||
"""Create a curl_cffi Session configured for OpenRent.
|
|
||||||
Uses Chrome TLS impersonation so AWS WAF cookies remain valid."""
|
|
||||||
session = Session(impersonate="chrome")
|
|
||||||
session.headers.update(
|
|
||||||
{
|
|
||||||
"User-Agent": user_agent,
|
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
||||||
"Accept-Language": "en-GB,en;q=0.9",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
for name, value in cookies.items():
|
|
||||||
session.cookies.set(name, value, domain="openrent.co.uk")
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# HTTP fetch with retry
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _status_label(code: int) -> str:
|
|
||||||
if code >= 500:
|
|
||||||
return "5xx"
|
|
||||||
return str(code)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_page(
|
|
||||||
client: Session,
|
|
||||||
url: str,
|
|
||||||
max_retries: int = 3,
|
|
||||||
) -> str | None:
|
|
||||||
"""GET HTML with retries on 429/5xx. Returns None on permanent failure.
|
|
||||||
WAF challenge (202 or 403 with challenge JS) raises WafChallengeError."""
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
resp = client.get(url, timeout=30)
|
|
||||||
openrent_requests_total.labels(status=_status_label(resp.status_code)).inc()
|
|
||||||
|
|
||||||
if resp.status_code == 200:
|
|
||||||
html = resp.text
|
|
||||||
# Detect WAF challenge page masquerading as 200
|
|
||||||
if "AwsWafIntegration" in html and "challenge.js" in html:
|
|
||||||
raise WafChallengeError(
|
|
||||||
"Got AWS WAF challenge page — cookies expired"
|
|
||||||
)
|
|
||||||
return html
|
|
||||||
|
|
||||||
if resp.status_code in (202, 403):
|
|
||||||
raise WafChallengeError(
|
|
||||||
f"HTTP {resp.status_code} — cookies likely expired"
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code in (429, 500, 502, 503, 504):
|
|
||||||
delay = RETRY_BASE_DELAY * (2**attempt)
|
|
||||||
log.warning(
|
|
||||||
"HTTP %d from %s, retry %d/%d in %.1fs",
|
|
||||||
resp.status_code,
|
|
||||||
url,
|
|
||||||
attempt + 1,
|
|
||||||
max_retries,
|
|
||||||
delay,
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
continue
|
|
||||||
|
|
||||||
log.error("HTTP %d from %s (non-retryable)", resp.status_code, url)
|
|
||||||
return None
|
|
||||||
|
|
||||||
except WafChallengeError:
|
|
||||||
raise
|
|
||||||
except RequestsError as e:
|
|
||||||
openrent_errors_total.labels(type=type(e).__name__).inc()
|
|
||||||
delay = RETRY_BASE_DELAY * (2**attempt)
|
|
||||||
log.warning(
|
|
||||||
"%s from %s, retry %d/%d in %.1fs",
|
|
||||||
type(e).__name__,
|
|
||||||
url,
|
|
||||||
attempt + 1,
|
|
||||||
max_retries,
|
|
||||||
delay,
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
openrent_errors_total.labels(type="retry_exhausted").inc()
|
|
||||||
log.error("All %d retries exhausted for %s", max_retries, url)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# HTML parsing
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_price_from_element(el) -> tuple[int, str] | None:
|
|
||||||
"""Extract price integer from a price element's text like '£2,100'."""
|
|
||||||
if not el:
|
|
||||||
return None
|
|
||||||
text = el.get_text(strip=True)
|
|
||||||
match = re.search(r"£([\d,]+)", text)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
return int(match.group(1).replace(",", ""))
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_price(text: str) -> tuple[int, str] | None:
|
|
||||||
"""Extract price and frequency from text like '£1,500 pcm' or '£350 pw'.
|
|
||||||
Returns (price_int, frequency) or None.
|
|
||||||
|
|
||||||
OpenRent card text shows both monthly and weekly prices (e.g.
|
|
||||||
'£2,800 per month £646 per week'), so check monthly *before* weekly
|
|
||||||
to match the first (monthly) price that the regex captures."""
|
|
||||||
match = re.search(r"£([\d,]+)", text)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
price = int(match.group(1).replace(",", ""))
|
|
||||||
lower = text.lower()
|
|
||||||
if "pcm" in lower or "per month" in lower or "/m" in lower:
|
|
||||||
return price, "monthly"
|
|
||||||
if "pw" in lower or "per week" in lower or "/w" in lower:
|
|
||||||
return price, "weekly"
|
|
||||||
if "pa" in lower or "per annum" in lower or "/y" in lower:
|
|
||||||
return price, "yearly"
|
|
||||||
# OpenRent defaults to pcm (per calendar month)
|
|
||||||
return price, "monthly"
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_bedrooms_from_title(title: str) -> int | None:
|
|
||||||
"""Extract bedroom count from title like '2 Bed Flat, Pimlico'."""
|
|
||||||
match = re.search(r"(\d+)\s*bed", title, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
return int(match.group(1))
|
|
||||||
if re.search(r"\bstudio\b", title, re.IGNORECASE):
|
|
||||||
return 0
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_beds_baths_from_features(
|
|
||||||
feature_items: list,
|
|
||||||
) -> tuple[int | None, int | None]:
|
|
||||||
"""Extract bedrooms and bathrooms from feature list items.
|
|
||||||
|
|
||||||
OpenRent search cards have <ul> with items like:
|
|
||||||
<li>1 Bed</li> <li>1 Bath</li> <li>Furnished</li>
|
|
||||||
"""
|
|
||||||
bedrooms = None
|
|
||||||
bathrooms = None
|
|
||||||
for li in feature_items:
|
|
||||||
text = li.get_text(strip=True).lower()
|
|
||||||
bed_match = re.search(r"(\d+)\s*bed", text)
|
|
||||||
if bed_match:
|
|
||||||
bedrooms = int(bed_match.group(1))
|
|
||||||
bath_match = re.search(r"(\d+)\s*bath", text)
|
|
||||||
if bath_match:
|
|
||||||
bathrooms = int(bath_match.group(1))
|
|
||||||
return bedrooms, bathrooms
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_postcode(text: str) -> str | None:
|
|
||||||
"""Extract full UK postcode from text like '2 Bed Flat, Pimlico, SW1V 2AA'.
|
|
||||||
Normalizes to include a space before the 3-char incode."""
|
|
||||||
match = re.search(r"([A-Z]{1,2}\d[A-Z0-9]?\s*\d[A-Z]{2})", text, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
raw = match.group(1).upper().strip()
|
|
||||||
# Ensure space before incode (last 3 chars): "IP265AT" → "IP26 5AT"
|
|
||||||
if " " not in raw and len(raw) >= 5:
|
|
||||||
return raw[:-3] + " " + raw[-3:]
|
|
||||||
return raw
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_outcode(text: str) -> str | None:
|
|
||||||
"""Extract UK outcode from text like '1 Bed Flat, Bank Chambers, SW1Y'.
|
|
||||||
|
|
||||||
Looks for an outcode pattern (e.g., SW1Y, E1, EC2A) at the end of the text
|
|
||||||
or after the last comma."""
|
|
||||||
# Try after last comma first (most reliable position in OpenRent titles)
|
|
||||||
parts = text.split(",")
|
|
||||||
if len(parts) > 1:
|
|
||||||
last_part = parts[-1].strip()
|
|
||||||
match = re.match(r"^([A-Z]{1,2}\d[A-Z0-9]?)$", last_part, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
return match.group(1).upper()
|
|
||||||
|
|
||||||
# Fall back to searching anywhere in text
|
|
||||||
match = re.search(r"\b([A-Z]{1,2}\d[A-Z0-9]?)\b", text, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
candidate = match.group(1).upper()
|
|
||||||
# Avoid matching things like "1 Bed" → "1B"
|
|
||||||
if len(candidate) >= 2 and not candidate[0].isdigit():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _infer_property_type(title: str) -> str:
|
|
||||||
"""Infer property type from title text.
|
|
||||||
|
|
||||||
Order matters: "Room in a Shared Flat" should be "Room" not "Flat",
|
|
||||||
so check "room" before "flat"."""
|
|
||||||
lower = title.lower()
|
|
||||||
if "room in" in lower or "room " in lower:
|
|
||||||
return "Room"
|
|
||||||
if "studio" in lower:
|
|
||||||
return "Studio"
|
|
||||||
if "flat" in lower or "apartment" in lower:
|
|
||||||
return "Flat"
|
|
||||||
if "maisonette" in lower:
|
|
||||||
return "Maisonette"
|
|
||||||
if "house" in lower:
|
|
||||||
return "House"
|
|
||||||
if "bungalow" in lower:
|
|
||||||
return "Bungalow"
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def parse_search_results(html: str) -> list[dict]:
|
|
||||||
"""Parse property data from OpenRent search results HTML.
|
|
||||||
|
|
||||||
Returns list of raw property dicts extracted from property cards.
|
|
||||||
|
|
||||||
Current OpenRent card structure (2026-03):
|
|
||||||
<a class="pli search-property-card" href="/property-to-rent/.../ID">
|
|
||||||
<div class="or-swiper" data-listing-id="ID">
|
|
||||||
<div class="pim"><span class="text-primary">£2,100</span> per month</div>
|
|
||||||
<div class="piw"><span class="text-primary">£485</span> per week</div>
|
|
||||||
<div class="fw-medium text-primary fs-3">1 Bed Flat, Location, SW1Y</div>
|
|
||||||
<ul>...<li>1 Bed</li><li>1 Bath</li><li>Furnished</li>...</ul>
|
|
||||||
"""
|
|
||||||
soup = BeautifulSoup(html, "lxml")
|
|
||||||
properties = []
|
|
||||||
|
|
||||||
# Property cards: <a class="pli search-property-card">
|
|
||||||
cards = soup.select("a.pli")
|
|
||||||
if not cards:
|
|
||||||
cards = soup.find_all("a", href=re.compile(r"/property-to-rent/"))
|
|
||||||
|
|
||||||
if not cards:
|
|
||||||
log.warning(
|
|
||||||
"No property cards found in search HTML (%d bytes). "
|
|
||||||
"CSS selectors may need updating.",
|
|
||||||
len(html),
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
for card in cards:
|
|
||||||
prop: dict = {}
|
|
||||||
|
|
||||||
# Extract property URL and ID from href
|
|
||||||
href = card.get("href", "")
|
|
||||||
if not href:
|
|
||||||
continue
|
|
||||||
|
|
||||||
prop["url"] = href if href.startswith("http") else OPENRENT_BASE + href
|
|
||||||
id_match = re.search(r"/(\d+)(?:\?|$|#)", href)
|
|
||||||
if id_match:
|
|
||||||
prop["id"] = id_match.group(1)
|
|
||||||
else:
|
|
||||||
# Try data-listing-id on the swiper element
|
|
||||||
swiper = card.select_one("[data-listing-id]")
|
|
||||||
if swiper:
|
|
||||||
prop["id"] = swiper["data-listing-id"]
|
|
||||||
else:
|
|
||||||
continue # can't use a property without an ID
|
|
||||||
|
|
||||||
# --- Price ---
|
|
||||||
# Prefer structured price elements over free-text parsing.
|
|
||||||
# Monthly price is in <div class="pim"><span class="text-primary">£X</span>
|
|
||||||
pim = card.select_one(".pim .text-primary, .pim span")
|
|
||||||
piw = card.select_one(".piw .text-primary, .piw span")
|
|
||||||
|
|
||||||
monthly_price = _extract_price_from_element(pim)
|
|
||||||
weekly_price = _extract_price_from_element(piw)
|
|
||||||
|
|
||||||
if monthly_price:
|
|
||||||
prop["price"] = monthly_price
|
|
||||||
prop["frequency"] = "monthly"
|
|
||||||
elif weekly_price:
|
|
||||||
prop["price"] = weekly_price
|
|
||||||
prop["frequency"] = "weekly"
|
|
||||||
else:
|
|
||||||
# Fall back to parsing card text
|
|
||||||
card_text = card.get_text(" ", strip=True)
|
|
||||||
price_result = _extract_price(card_text)
|
|
||||||
if price_result:
|
|
||||||
prop["price"], prop["frequency"] = price_result
|
|
||||||
|
|
||||||
# --- Title / Address ---
|
|
||||||
# The property title is in a div with classes "fw-medium text-primary fs-3"
|
|
||||||
# e.g., "1 Bed Flat, Bank Chambers, SW1Y"
|
|
||||||
title_el = card.select_one("div.fw-medium.fs-3")
|
|
||||||
if not title_el:
|
|
||||||
# Fallback: try image alt text which also has the title
|
|
||||||
img = card.select_one("img.propertyPic")
|
|
||||||
if img and img.get("alt"):
|
|
||||||
prop["title"] = img["alt"]
|
|
||||||
else:
|
|
||||||
# Last resort: extract from card text, excluding price/nav noise
|
|
||||||
prop["title"] = ""
|
|
||||||
else:
|
|
||||||
prop["title"] = title_el.get_text(strip=True)
|
|
||||||
|
|
||||||
# --- Bedrooms / Bathrooms from feature list ---
|
|
||||||
feature_list = card.select("ul li")
|
|
||||||
beds_from_features, baths_from_features = _extract_beds_baths_from_features(
|
|
||||||
feature_list,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Bedrooms: prefer feature list, fall back to title parsing
|
|
||||||
if beds_from_features is not None:
|
|
||||||
prop["bedrooms"] = beds_from_features
|
|
||||||
else:
|
|
||||||
beds = _extract_bedrooms_from_title(prop.get("title", ""))
|
|
||||||
if beds is not None:
|
|
||||||
prop["bedrooms"] = beds
|
|
||||||
|
|
||||||
if baths_from_features is not None:
|
|
||||||
prop["bathrooms"] = baths_from_features
|
|
||||||
|
|
||||||
# --- Property type from title ---
|
|
||||||
title = prop.get("title", "")
|
|
||||||
prop["property_type"] = _infer_property_type(title)
|
|
||||||
|
|
||||||
# --- Postcode / outcode from title ---
|
|
||||||
postcode = _extract_postcode(title)
|
|
||||||
if postcode:
|
|
||||||
prop["postcode"] = postcode
|
|
||||||
else:
|
|
||||||
outcode = _extract_outcode(title)
|
|
||||||
if outcode:
|
|
||||||
prop["outcode"] = outcode
|
|
||||||
|
|
||||||
# --- Description snippet ---
|
|
||||||
desc_el = card.select_one(".line-clamp-2")
|
|
||||||
if desc_el:
|
|
||||||
prop["description"] = desc_el.get_text(strip=True)
|
|
||||||
|
|
||||||
# --- Coordinates from data attributes (may not be present on cards) ---
|
|
||||||
for el in [card] + card.select("[data-lat], [data-latitude]"):
|
|
||||||
lat = el.get("data-lat") or el.get("data-latitude")
|
|
||||||
lng = el.get("data-lng") or el.get("data-longitude") or el.get("data-lon")
|
|
||||||
if lat and lng:
|
|
||||||
try:
|
|
||||||
prop["lat"] = float(lat)
|
|
||||||
prop["lng"] = float(lng)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
break
|
|
||||||
|
|
||||||
properties.append(prop)
|
|
||||||
|
|
||||||
log.debug("Parsed %d property cards from search HTML", len(properties))
|
|
||||||
return properties
|
|
||||||
|
|
||||||
|
|
||||||
def parse_property_detail(html: str) -> dict:
|
|
||||||
"""Parse a single property detail page for additional data.
|
|
||||||
|
|
||||||
Current detail page structure (2026-03):
|
|
||||||
- <h1> has the full title (e.g., "Room in a Shared House, Lime Tree Court, AL2")
|
|
||||||
- <div id="map" data-lat="..." data-lng="..."> has coordinates
|
|
||||||
- Tables have "Rent PCM", "Deposit", "Bills Included", etc. (NOT bedrooms)
|
|
||||||
- Description in elements with class containing "description"
|
|
||||||
"""
|
|
||||||
soup = BeautifulSoup(html, "lxml")
|
|
||||||
details: dict = {}
|
|
||||||
|
|
||||||
# --- Title from h1 ---
|
|
||||||
h1 = soup.select_one("h1")
|
|
||||||
if h1:
|
|
||||||
title_text = h1.get_text(strip=True)
|
|
||||||
# Validate it's not a nav/modal element (e.g. "Log in")
|
|
||||||
if len(title_text) > 10 and "log in" not in title_text.lower():
|
|
||||||
details["title"] = title_text
|
|
||||||
postcode = _extract_postcode(title_text)
|
|
||||||
if postcode:
|
|
||||||
details["postcode"] = postcode
|
|
||||||
|
|
||||||
# --- Coordinates from map element ---
|
|
||||||
# The map div has id="map" with data-lat and data-lng
|
|
||||||
map_el = soup.select_one("#map[data-lat]")
|
|
||||||
if not map_el:
|
|
||||||
# Fallback: any element with data-lat (but prefer #map)
|
|
||||||
map_el = soup.select_one("[data-lat]")
|
|
||||||
if map_el:
|
|
||||||
lat = map_el.get("data-lat")
|
|
||||||
lng = map_el.get("data-lng") or map_el.get("data-lon")
|
|
||||||
if lat and lng:
|
|
||||||
try:
|
|
||||||
details["lat"] = float(lat)
|
|
||||||
details["lng"] = float(lng)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# --- Parse tables for rent and property details ---
|
|
||||||
for table in soup.select("table"):
|
|
||||||
for row in table.select("tr"):
|
|
||||||
cells = row.select("td")
|
|
||||||
if len(cells) < 2:
|
|
||||||
continue
|
|
||||||
label = cells[0].get_text(strip=True).lower()
|
|
||||||
value = cells[1].get_text(strip=True)
|
|
||||||
|
|
||||||
if "rent" in label and "pcm" in label:
|
|
||||||
match = re.search(r"£([\d,]+)", value)
|
|
||||||
if match:
|
|
||||||
details["price"] = int(match.group(1).replace(",", ""))
|
|
||||||
elif "bedroom" in label:
|
|
||||||
match = re.search(r"(\d+)", value)
|
|
||||||
if match:
|
|
||||||
details["bedrooms"] = int(match.group(1))
|
|
||||||
elif "bathroom" in label:
|
|
||||||
match = re.search(r"(\d+)", value)
|
|
||||||
if match:
|
|
||||||
details["bathrooms"] = int(match.group(1))
|
|
||||||
elif "type" in label and "property" in label:
|
|
||||||
details["property_type"] = value
|
|
||||||
elif "available" in label or "move" in label:
|
|
||||||
details["available_date"] = value
|
|
||||||
elif "furnish" in label:
|
|
||||||
details["furnished"] = value
|
|
||||||
|
|
||||||
# --- Coordinates from inline JavaScript (last resort) ---
|
|
||||||
if "lat" not in details:
|
|
||||||
for script in soup.select("script"):
|
|
||||||
text = script.string or ""
|
|
||||||
lat_match = re.search(r'"latitude"\s*:\s*([\d.-]+)', text)
|
|
||||||
lng_match = re.search(r'"longitude"\s*:\s*([\d.-]+)', text)
|
|
||||||
if lat_match and lng_match:
|
|
||||||
try:
|
|
||||||
details["lat"] = float(lat_match.group(1))
|
|
||||||
details["lng"] = float(lng_match.group(1))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
break
|
|
||||||
|
|
||||||
# --- Description for floor area ---
|
|
||||||
desc_el = soup.select_one(".description, [class*='description'], #description")
|
|
||||||
if desc_el:
|
|
||||||
details["description"] = desc_el.get_text(strip=True)
|
|
||||||
|
|
||||||
return details
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Property type mapping & floor area
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def map_property_type(raw_type: str | None) -> str:
|
|
||||||
"""Map OpenRent property type to canonical type."""
|
|
||||||
if not raw_type:
|
|
||||||
return "Other"
|
|
||||||
canonical = PROPERTY_TYPE_MAP.get(raw_type)
|
|
||||||
if canonical:
|
|
||||||
return canonical
|
|
||||||
lower = raw_type.lower()
|
|
||||||
if "room" in lower or "shared" in lower:
|
|
||||||
return "Other"
|
|
||||||
if (
|
|
||||||
"flat" in lower
|
|
||||||
or "apartment" in lower
|
|
||||||
or "maisonette" in lower
|
|
||||||
or "studio" in lower
|
|
||||||
):
|
|
||||||
return "Flats/Maisonettes"
|
|
||||||
if "detached" in lower and "semi" not in lower:
|
|
||||||
return "Detached"
|
|
||||||
if "semi" in lower:
|
|
||||||
return "Semi-Detached"
|
|
||||||
if "terrace" in lower or "mews" in lower:
|
|
||||||
return "Terraced"
|
|
||||||
if "house" in lower:
|
|
||||||
return "Detached"
|
|
||||||
log.debug("Unknown property type: %r — mapping to Other", raw_type)
|
|
||||||
return "Other"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_floor_area(description: str | None) -> float | None:
|
|
||||||
"""Try to extract floor area from description text."""
|
|
||||||
if not description:
|
|
||||||
return None
|
|
||||||
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*ft", description, re.IGNORECASE)
|
|
||||||
if m:
|
|
||||||
sqft = float(m.group(1).replace(",", ""))
|
|
||||||
return validate_floor_area(round(sqft * 0.092903, 1))
|
|
||||||
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*m", description, re.IGNORECASE)
|
|
||||||
if m:
|
|
||||||
return validate_floor_area(round(float(m.group(1).replace(",", "")), 1))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Transform & search
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_outcode_postcodes(
|
|
||||||
outcode: str,
|
|
||||||
pc_coords: dict[str, tuple[float, float]],
|
|
||||||
) -> list[str]:
|
|
||||||
"""Get all postcodes for an outcode from the postcode coordinates lookup."""
|
|
||||||
# ONSPD 7-char format: 4-char outcodes have no space before incode
|
|
||||||
# (e.g., "BH191AB"), while shorter outcodes do (e.g., "E14 5AB").
|
|
||||||
prefix = outcode + " "
|
|
||||||
results = [pcd for pcd in pc_coords if pcd.startswith(prefix)]
|
|
||||||
if not results and len(outcode) >= 4:
|
|
||||||
results = [pcd for pcd in pc_coords if pcd.startswith(outcode) and len(pcd) > len(outcode)]
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_or_date(date_str: str) -> str:
|
|
||||||
"""Parse OpenRent date strings to ISO format (YYYY-MM-DD).
|
|
||||||
Handles 'Today', 'Tomorrow', and 'DD Month, YYYY' formats."""
|
|
||||||
if not date_str:
|
|
||||||
return ""
|
|
||||||
stripped = date_str.strip()
|
|
||||||
lower = stripped.lower()
|
|
||||||
if lower == "today":
|
|
||||||
from datetime import datetime
|
|
||||||
return datetime.now().strftime("%Y-%m-%d")
|
|
||||||
if lower == "tomorrow":
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
return (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d")
|
|
||||||
# Try "DD Month, YYYY" format (e.g., "01 April, 2026")
|
|
||||||
from datetime import datetime
|
|
||||||
for fmt in ("%d %B, %Y", "%d %B %Y"):
|
|
||||||
try:
|
|
||||||
return datetime.strptime(stripped, fmt).strftime("%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return date_str # Return as-is if unparseable
|
|
||||||
|
|
||||||
|
|
||||||
def transform_property(
|
|
||||||
search_data: dict,
|
|
||||||
detail_data: dict | None,
|
|
||||||
pc_index: PostcodeSpatialIndex,
|
|
||||||
pc_coords: dict[str, tuple[float, float]],
|
|
||||||
) -> dict | None:
|
|
||||||
"""Transform OpenRent property data into our output schema.
|
|
||||||
|
|
||||||
Merges data from the search results page and (optionally) the detail page.
|
|
||||||
Uses pc_coords (postcode -> lat/lng) as a fallback when coordinates are
|
|
||||||
missing but a postcode is available.
|
|
||||||
"""
|
|
||||||
detail = detail_data or {}
|
|
||||||
|
|
||||||
# Merge: detail page data takes precedence
|
|
||||||
lat = detail.get("lat") or search_data.get("lat")
|
|
||||||
lng = detail.get("lng") or search_data.get("lng")
|
|
||||||
price = detail.get("price") or search_data.get("price")
|
|
||||||
if not price or int(price) <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
frequency = search_data.get("frequency", "monthly")
|
|
||||||
|
|
||||||
# Get postcode: detail page > search card
|
|
||||||
postcode = detail.get("postcode") or search_data.get("postcode")
|
|
||||||
|
|
||||||
if lat is not None and lng is not None:
|
|
||||||
# Validate coordinates are in England
|
|
||||||
if not (49 <= lat <= 56 and -7 <= lng <= 2):
|
|
||||||
log.debug("Coords outside England: lat=%.4f lng=%.4f — skipping", lat, lng)
|
|
||||||
return None
|
|
||||||
if not postcode:
|
|
||||||
if pc_index:
|
|
||||||
postcode = pc_index.nearest(lat, lng)
|
|
||||||
elif search_data.get("outcode"):
|
|
||||||
# No spatial index — try outcode lookup as fallback
|
|
||||||
outcode_pcs = _resolve_outcode_postcodes(
|
|
||||||
search_data["outcode"],
|
|
||||||
pc_coords,
|
|
||||||
)
|
|
||||||
if outcode_pcs:
|
|
||||||
postcode = outcode_pcs[0]
|
|
||||||
elif postcode:
|
|
||||||
# Have postcode but no coordinates — look up centroid from arcgis data
|
|
||||||
coords = pc_coords.get(postcode)
|
|
||||||
if coords:
|
|
||||||
lat, lng = coords
|
|
||||||
else:
|
|
||||||
log.debug("Postcode %s not in arcgis data — skipping", postcode)
|
|
||||||
return None
|
|
||||||
elif search_data.get("outcode"):
|
|
||||||
# Have only outcode — find postcodes in that outcode and use centroid
|
|
||||||
outcode = search_data["outcode"]
|
|
||||||
outcode_postcodes = _resolve_outcode_postcodes(outcode, pc_coords)
|
|
||||||
if outcode_postcodes:
|
|
||||||
# Use the first postcode as a rough approximation
|
|
||||||
postcode = outcode_postcodes[0]
|
|
||||||
lat, lng = pc_coords[postcode]
|
|
||||||
else:
|
|
||||||
log.debug("No postcodes found for outcode %s — skipping", outcode)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not postcode:
|
|
||||||
log.debug("No postcode for property — skipping")
|
|
||||||
return None
|
|
||||||
|
|
||||||
raw_beds = detail.get("bedrooms") or search_data.get("bedrooms", 0) or 0
|
|
||||||
raw_baths = detail.get("bathrooms") or search_data.get("bathrooms", 0) or 0
|
|
||||||
bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0
|
|
||||||
bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0
|
|
||||||
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
|
|
||||||
log.warning(
|
|
||||||
"OpenRent %s: implausible beds=%d baths=%d (capped to 0)",
|
|
||||||
search_data.get("id", "?"), raw_beds, raw_baths,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Title: prefer detail page (has h1 with full title)
|
|
||||||
title = detail.get("title") or search_data.get("title", "")
|
|
||||||
|
|
||||||
# Address: take the middle part of the title (skip the "N Bed Type" prefix
|
|
||||||
# and the outcode suffix). E.g., "1 Bed Flat, Bank Chambers, SW1Y" -> "Bank Chambers"
|
|
||||||
address = ""
|
|
||||||
if title:
|
|
||||||
parts = [p.strip() for p in title.split(",")]
|
|
||||||
if len(parts) >= 3:
|
|
||||||
# Skip first (type) and last (outcode), join the middle
|
|
||||||
address = ", ".join(parts[1:-1])
|
|
||||||
elif len(parts) == 2:
|
|
||||||
# Could be "Location, OUTCODE" or "Type, Location"
|
|
||||||
# If last part looks like an outcode, use the first part
|
|
||||||
if re.match(r"^[A-Z]{1,2}\d", parts[-1].strip()):
|
|
||||||
address = parts[0]
|
|
||||||
else:
|
|
||||||
address = parts[1]
|
|
||||||
else:
|
|
||||||
address = title
|
|
||||||
|
|
||||||
# Property type: prefer detail, then search card, then infer from title
|
|
||||||
property_type = detail.get("property_type") or search_data.get("property_type", "")
|
|
||||||
if not property_type and title:
|
|
||||||
property_type = _infer_property_type(title)
|
|
||||||
|
|
||||||
prop_id = search_data.get("id", "")
|
|
||||||
listing_url = search_data.get(
|
|
||||||
"url",
|
|
||||||
f"{OPENRENT_BASE}/{prop_id}" if prop_id else "",
|
|
||||||
)
|
|
||||||
description = detail.get("description") or search_data.get("description", "")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": f"or_{prop_id}",
|
|
||||||
"Bedrooms": bedrooms,
|
|
||||||
"Bathrooms": bathrooms,
|
|
||||||
"Number of bedrooms & living rooms": bedrooms,
|
|
||||||
"lon": lng,
|
|
||||||
"lat": lat,
|
|
||||||
"Postcode": normalize_postcode(postcode),
|
|
||||||
"Address per Property Register": address,
|
|
||||||
# OpenRent is a rental-only platform — tenure (Freehold/Leasehold) is a
|
|
||||||
# property ownership concept that doesn't apply to rental listings. The
|
|
||||||
# landlord's tenure is not shown on OpenRent listing pages.
|
|
||||||
"Leasehold/Freehold": None,
|
|
||||||
"Property type": map_property_type(property_type),
|
|
||||||
"Property sub-type": normalize_sub_type(property_type),
|
|
||||||
"price": int(price),
|
|
||||||
"price_frequency": frequency,
|
|
||||||
"Price qualifier": "",
|
|
||||||
"Total floor area (sqm)": parse_floor_area(description),
|
|
||||||
"Listing URL": listing_url,
|
|
||||||
"Listing features": [],
|
|
||||||
"first_visible_date": _parse_or_date(detail.get("available_date", "")),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def search_outcode(
|
|
||||||
client: Session,
|
|
||||||
outcode: str,
|
|
||||||
pc_index: PostcodeSpatialIndex,
|
|
||||||
pc_coords: dict[str, tuple[float, float]],
|
|
||||||
fetch_details: bool = True,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Search OpenRent for rental properties in one outcode.
|
|
||||||
|
|
||||||
1. Fetches the search results page for the outcode
|
|
||||||
2. Parses property cards from the HTML (title, price, beds, baths)
|
|
||||||
3. Fetches each property's detail page for coordinates
|
|
||||||
4. Transforms to common output schema
|
|
||||||
|
|
||||||
The search card provides most data (price, bedrooms, bathrooms, title,
|
|
||||||
property type). Detail pages are needed primarily for precise coordinates
|
|
||||||
and full postcodes. When detail pages fail, we fall back to outcode-level
|
|
||||||
coordinates from the postcode lookup.
|
|
||||||
"""
|
|
||||||
search_url = f"{OPENRENT_BASE}/properties-to-rent/?term={outcode}&isLive=true"
|
|
||||||
|
|
||||||
html = fetch_page(client, search_url)
|
|
||||||
if not html:
|
|
||||||
return []
|
|
||||||
|
|
||||||
search_results = parse_search_results(html)
|
|
||||||
if not search_results:
|
|
||||||
return []
|
|
||||||
|
|
||||||
properties = []
|
|
||||||
for search_data in search_results:
|
|
||||||
detail_data = None
|
|
||||||
|
|
||||||
# Skip detail page if we already have coordinates or a resolvable postcode
|
|
||||||
has_coords = (
|
|
||||||
search_data.get("lat") is not None
|
|
||||||
and search_data.get("lng") is not None
|
|
||||||
)
|
|
||||||
has_resolvable_pc = (
|
|
||||||
search_data.get("postcode")
|
|
||||||
and pc_coords
|
|
||||||
and search_data["postcode"] in pc_coords
|
|
||||||
)
|
|
||||||
needs_detail = (
|
|
||||||
fetch_details
|
|
||||||
and search_data.get("url")
|
|
||||||
and not has_coords
|
|
||||||
and not has_resolvable_pc
|
|
||||||
)
|
|
||||||
|
|
||||||
if needs_detail:
|
|
||||||
detail_html = fetch_page(client, search_data["url"])
|
|
||||||
if detail_html:
|
|
||||||
detail_data = parse_property_detail(detail_html)
|
|
||||||
# Shorter delay for detail pages (within same outcode)
|
|
||||||
time.sleep(0.15)
|
|
||||||
|
|
||||||
transformed = transform_property(
|
|
||||||
search_data,
|
|
||||||
detail_data,
|
|
||||||
pc_index,
|
|
||||||
pc_coords,
|
|
||||||
)
|
|
||||||
if transformed:
|
|
||||||
properties.append(transformed)
|
|
||||||
openrent_properties_scraped.labels(channel="rent").inc()
|
|
||||||
|
|
||||||
return properties
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
[project]
|
|
||||||
name = "finder"
|
|
||||||
version = "0.1.0"
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
dependencies = [
|
|
||||||
"flask",
|
|
||||||
"httpx",
|
|
||||||
"curl_cffi",
|
|
||||||
"polars",
|
|
||||||
"fake-useragent>=2.2.0",
|
|
||||||
"prometheus-client",
|
|
||||||
"beautifulsoup4",
|
|
||||||
"lxml",
|
|
||||||
"playwright>=1.58.0",
|
|
||||||
"playwright-stealth>=2.0.2",
|
|
||||||
"camoufox>=0.4.11",
|
|
||||||
]
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from constants import (
|
|
||||||
PAGE_SIZE,
|
|
||||||
DELAY_BETWEEN_PAGES,
|
|
||||||
SEARCH_URL,
|
|
||||||
TYPEAHEAD_URL,
|
|
||||||
)
|
|
||||||
from http_client import fetch_with_retry
|
|
||||||
from spatial import PostcodeSpatialIndex
|
|
||||||
from transform import transform_property
|
|
||||||
|
|
||||||
log = logging.getLogger("rightmove")
|
|
||||||
|
|
||||||
# Outcode ID cache (Rightmove typeahead → internal ID)
|
|
||||||
outcode_cache: dict[str, str] = {}
|
|
||||||
|
|
||||||
# Rightmove hard-caps pagination at index 1008 (42 pages × 24 results).
|
|
||||||
# Requesting index >= 1008 returns HTTP 400.
|
|
||||||
_MAX_INDEX = 1008
|
|
||||||
|
|
||||||
# Property type filters for splitting overcapped searches. Each sub-query
|
|
||||||
# gets its own 1008 cap, so we can recover listings beyond the unfiltered limit.
|
|
||||||
_PROPERTY_TYPES = [
|
|
||||||
"detached", "semi-detached", "terraced", "flat",
|
|
||||||
"bungalow", "park-home", "land",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_outcode_id(client: httpx.Client, outcode: str) -> str | None:
|
|
||||||
"""Look up Rightmove's internal ID for an outcode via typeahead API."""
|
|
||||||
if outcode in outcode_cache:
|
|
||||||
return outcode_cache[outcode]
|
|
||||||
|
|
||||||
data = fetch_with_retry(
|
|
||||||
client, TYPEAHEAD_URL, {"query": outcode, "limit": "10", "exclude": "STREET"}
|
|
||||||
)
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for match in data.get("matches", []):
|
|
||||||
if match.get("type") == "OUTCODE" and match.get("displayName") == outcode:
|
|
||||||
rid = str(match["id"])
|
|
||||||
outcode_cache[outcode] = rid
|
|
||||||
return rid
|
|
||||||
|
|
||||||
log.debug("Outcode %s not found in typeahead results", outcode)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _paginate(
|
|
||||||
client: httpx.Client,
|
|
||||||
outcode_id: str,
|
|
||||||
outcode: str,
|
|
||||||
channel_cfg: dict,
|
|
||||||
pc_index: PostcodeSpatialIndex,
|
|
||||||
extra_params: dict | None = None,
|
|
||||||
) -> tuple[list[dict], int]:
|
|
||||||
"""Paginate through search results. Returns (properties, result_count)."""
|
|
||||||
properties = []
|
|
||||||
index = 0
|
|
||||||
result_count = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
params = {
|
|
||||||
"useLocationIdentifier": "true",
|
|
||||||
"locationIdentifier": f"OUTCODE^{outcode_id}",
|
|
||||||
"index": str(index),
|
|
||||||
"sortType": channel_cfg["sortType"],
|
|
||||||
"channel": channel_cfg["channel"],
|
|
||||||
"transactionType": channel_cfg["transactionType"],
|
|
||||||
}
|
|
||||||
if extra_params:
|
|
||||||
params.update(extra_params)
|
|
||||||
|
|
||||||
data = fetch_with_retry(client, SEARCH_URL, params)
|
|
||||||
if not data:
|
|
||||||
log.warning(
|
|
||||||
"Failed to fetch index %d for %s/%s",
|
|
||||||
index,
|
|
||||||
outcode,
|
|
||||||
channel_cfg["channel"],
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
raw_props = data.get("properties", [])
|
|
||||||
if not raw_props:
|
|
||||||
break
|
|
||||||
|
|
||||||
for prop in raw_props:
|
|
||||||
transformed = transform_property(prop, outcode, pc_index)
|
|
||||||
if transformed:
|
|
||||||
properties.append(transformed)
|
|
||||||
|
|
||||||
# Check if there are more pages
|
|
||||||
result_count_str = data.get("resultCount", "0")
|
|
||||||
result_count = int(result_count_str.replace(",", ""))
|
|
||||||
index += PAGE_SIZE
|
|
||||||
|
|
||||||
if index >= result_count:
|
|
||||||
break
|
|
||||||
|
|
||||||
time.sleep(DELAY_BETWEEN_PAGES)
|
|
||||||
|
|
||||||
return properties, result_count
|
|
||||||
|
|
||||||
|
|
||||||
def search_outcode(
|
|
||||||
client: httpx.Client,
|
|
||||||
outcode_id: str,
|
|
||||||
outcode: str,
|
|
||||||
channel_cfg: dict,
|
|
||||||
pc_index: PostcodeSpatialIndex,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Paginate through search results for one outcode+channel. Returns transformed properties.
|
|
||||||
|
|
||||||
When the unfiltered result count exceeds 1008 (Rightmove's hard pagination cap),
|
|
||||||
re-queries per property type to recover listings beyond the cap.
|
|
||||||
"""
|
|
||||||
properties, result_count = _paginate(
|
|
||||||
client, outcode_id, outcode, channel_cfg, pc_index
|
|
||||||
)
|
|
||||||
|
|
||||||
if result_count <= _MAX_INDEX:
|
|
||||||
return properties
|
|
||||||
|
|
||||||
# Hit the 1008 cap — re-search per property type to get full coverage
|
|
||||||
ch = channel_cfg["channel"]
|
|
||||||
log.info(
|
|
||||||
"%s/%s: %d results exceed %d cap, splitting by property type",
|
|
||||||
outcode, ch, result_count, _MAX_INDEX,
|
|
||||||
)
|
|
||||||
|
|
||||||
all_by_id: dict[str, dict] = {p["id"]: p for p in properties}
|
|
||||||
|
|
||||||
for pt in _PROPERTY_TYPES:
|
|
||||||
pt_props, _ = _paginate(
|
|
||||||
client, outcode_id, outcode, channel_cfg, pc_index,
|
|
||||||
extra_params={"propertyTypes": pt},
|
|
||||||
)
|
|
||||||
new = 0
|
|
||||||
for p in pt_props:
|
|
||||||
if p["id"] not in all_by_id:
|
|
||||||
all_by_id[p["id"]] = p
|
|
||||||
new += 1
|
|
||||||
if new:
|
|
||||||
log.debug("%s/%s type=%s: +%d new properties", outcode, ch, pt, new)
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"%s/%s: type split recovered %d → %d properties",
|
|
||||||
outcode, ch, len(properties), len(all_by_id),
|
|
||||||
)
|
|
||||||
return list(all_by_id.values())
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,52 +0,0 @@
|
||||||
The API works as follows, you must search for outcodes, such as E11, then hit https://los.rightmove.co.uk/typeahead?query=E11&limit=10&exclude=STREET which will return something like:
|
|
||||||
|
|
||||||
{
|
|
||||||
"matches": [
|
|
||||||
{
|
|
||||||
"id": "746",
|
|
||||||
"type": "OUTCODE",
|
|
||||||
"displayName": "E11",
|
|
||||||
"highlighting": "<span class='highlightLetter'>E11</span>",
|
|
||||||
"highlights": [
|
|
||||||
{
|
|
||||||
"text": "E11",
|
|
||||||
"highlighted": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "749",
|
|
||||||
"type": "OUTCODE",
|
|
||||||
"displayName": "E14",
|
|
||||||
"highlighting": "displayName",
|
|
||||||
"highlights": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "752",
|
|
||||||
"type": "OUTCODE",
|
|
||||||
"displayName": "E17",
|
|
||||||
"highlighting": "displayName",
|
|
||||||
"highlights": []
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
We need to find the id of the object which has "type": "OUTCODE", and displayName matching the outcode we searched for, in this case E11, which is 746. Then we can hit the search endpoint with that id, and it will return the properties for that outcode:
|
|
||||||
|
|
||||||
https://www.rightmove.co.uk/api/property-search/listing/search?useLocationIdentifier=true&locationIdentifier=OUTCODE%5E746&buy=For+sale&_includeSSTC=on&index=0&sortType=2&channel=BUY&transactionType=BUY&displayLocationIdentifier=E12.html
|
|
||||||
|
|
||||||
You can see the example response to this at [[buy.json]]
|
|
||||||
|
|
||||||
You must set locationIdentifier=OUTCODE%5E{id} where id is 746 in this case, so it's 746 locationIdentifier=OUTCODE%5E746. Paging works by increasing index by the number of results per page, which is 24. So the next page would be index=24, then index=48, etc.
|
|
||||||
|
|
||||||
|
|
||||||
The rental endpoint works similarly:
|
|
||||||
|
|
||||||
https://www.rightmove.co.uk/api/property-search/listing/search?locationIdentifier=OUTCODE%5E745&index=0&sortType=6&channel=RENT&transactionType=LETTING&displayLocationIdentifier=E16.html
|
|
||||||
|
|
||||||
https://www.rightmove.co.uk/api/property-search/listing/search?locationIdentifier=OUTCODE%5E752&index=48&sortType=6&channel=RENT&transactionType=LETTING&displayLocationIdentifier=E17.html
|
|
||||||
|
|
||||||
|
|
||||||
See a response example for the rental endpoint at [[rent.json]]
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,993 +0,0 @@
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from constants import (
|
|
||||||
ARCGIS_PATH,
|
|
||||||
CHANNELS,
|
|
||||||
CHECKPOINT_INTERVAL,
|
|
||||||
DATA_DIR,
|
|
||||||
DELAY_BETWEEN_OUTCODES,
|
|
||||||
HOMECOUK_CONCURRENCY,
|
|
||||||
RELOAD_URL,
|
|
||||||
SCRAPE_HOMECOUK,
|
|
||||||
SCRAPE_OPENRENT,
|
|
||||||
SCRAPE_RIGHTMOVE,
|
|
||||||
SCRAPE_ZOOPLA,
|
|
||||||
SEED,
|
|
||||||
)
|
|
||||||
from homecouk import CookiesExpiredError
|
|
||||||
from homecouk import load_cookies as load_homecouk_cookies
|
|
||||||
from homecouk import make_client as make_homecouk_client
|
|
||||||
from homecouk import search_outcode as homecouk_search_outcode
|
|
||||||
from http_client import make_client
|
|
||||||
from metrics import (
|
|
||||||
cookie_refreshes_total,
|
|
||||||
cross_source_dedup_total,
|
|
||||||
homecouk_enabled,
|
|
||||||
openrent_enabled,
|
|
||||||
scrape_elapsed_seconds,
|
|
||||||
scrape_errors_total,
|
|
||||||
scrape_outcodes_done,
|
|
||||||
scrape_outcodes_total,
|
|
||||||
scrape_properties_total,
|
|
||||||
scrape_state,
|
|
||||||
zoopla_enabled,
|
|
||||||
)
|
|
||||||
from openrent import WafChallengeError
|
|
||||||
from openrent import load_cookies as load_openrent_cookies
|
|
||||||
from openrent import make_client as make_openrent_client
|
|
||||||
from openrent import search_outcode as openrent_search_outcode
|
|
||||||
from rightmove import resolve_outcode_id, search_outcode
|
|
||||||
from zoopla import TurnstileError
|
|
||||||
from zoopla import launch_browser as launch_zoopla_browser
|
|
||||||
from zoopla import search_outcode as zoopla_search_outcode
|
|
||||||
from spatial import PostcodeSpatialIndex
|
|
||||||
from storage import write_parquet
|
|
||||||
|
|
||||||
log = logging.getLogger("rightmove")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ScrapeStatus:
|
|
||||||
state: str = "idle" # idle | running | done | error
|
|
||||||
channel: str = ""
|
|
||||||
outcode: str = ""
|
|
||||||
outcodes_done: int = 0
|
|
||||||
outcodes_total: int = 0
|
|
||||||
properties_buy: int = 0
|
|
||||||
properties_rent: int = 0
|
|
||||||
# Per-source counts (combined across channels)
|
|
||||||
rm_properties: int = 0
|
|
||||||
hk_properties: int = 0
|
|
||||||
or_properties: int = 0
|
|
||||||
zp_properties: int = 0
|
|
||||||
errors: list[str] = field(default_factory=list)
|
|
||||||
started_at: float = 0.0
|
|
||||||
finished_at: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
status = ScrapeStatus()
|
|
||||||
status_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_gauges() -> None:
|
|
||||||
"""Push current ScrapeStatus values into Prometheus gauges. Must hold status_lock."""
|
|
||||||
for state in ("idle", "running", "done", "error"):
|
|
||||||
scrape_state.labels(state=state).set(1 if status.state == state else 0)
|
|
||||||
scrape_outcodes_done.set(status.outcodes_done)
|
|
||||||
scrape_outcodes_total.set(status.outcodes_total)
|
|
||||||
scrape_properties_total.labels(channel="buy", source="total").set(
|
|
||||||
status.properties_buy
|
|
||||||
)
|
|
||||||
scrape_properties_total.labels(channel="rent", source="total").set(
|
|
||||||
status.properties_rent
|
|
||||||
)
|
|
||||||
# Per-source totals (across both channels)
|
|
||||||
for ch in ("buy", "rent"):
|
|
||||||
scrape_properties_total.labels(channel=ch, source="rightmove").set(
|
|
||||||
status.rm_properties
|
|
||||||
)
|
|
||||||
scrape_properties_total.labels(channel=ch, source="homecouk").set(
|
|
||||||
status.hk_properties
|
|
||||||
)
|
|
||||||
scrape_properties_total.labels(channel=ch, source="openrent").set(
|
|
||||||
status.or_properties
|
|
||||||
)
|
|
||||||
scrape_properties_total.labels(channel=ch, source="zoopla").set(
|
|
||||||
status.zp_properties
|
|
||||||
)
|
|
||||||
if status.started_at:
|
|
||||||
end = status.finished_at if status.finished_at else time.time()
|
|
||||||
scrape_elapsed_seconds.set(end - status.started_at)
|
|
||||||
else:
|
|
||||||
scrape_elapsed_seconds.set(0)
|
|
||||||
|
|
||||||
|
|
||||||
def load_outcodes() -> list[str]:
|
|
||||||
"""Load England-only outcodes from arcgis parquet."""
|
|
||||||
log.info("Loading outcodes from %s", ARCGIS_PATH)
|
|
||||||
df = pl.read_parquet(ARCGIS_PATH, columns=["pcd", "ctry", "lat", "long"])
|
|
||||||
england = df.filter(pl.col("ctry") == "E92000001")
|
|
||||||
log.info("England postcodes: %d", len(england))
|
|
||||||
|
|
||||||
outcodes = (
|
|
||||||
england.select(
|
|
||||||
pl.col("pcd").str.extract(r"^([A-Z]{1,2}\d[A-Z0-9]?)", 1).alias("outcode")
|
|
||||||
)
|
|
||||||
.drop_nulls()
|
|
||||||
.get_column("outcode")
|
|
||||||
.unique()
|
|
||||||
.sort()
|
|
||||||
.to_list()
|
|
||||||
)
|
|
||||||
log.info("Unique England outcodes: %d", len(outcodes))
|
|
||||||
return outcodes
|
|
||||||
|
|
||||||
|
|
||||||
def build_postcode_index() -> PostcodeSpatialIndex:
|
|
||||||
"""Build spatial index from arcgis England postcodes."""
|
|
||||||
log.info("Building postcode spatial index from %s", ARCGIS_PATH)
|
|
||||||
df = pl.read_parquet(ARCGIS_PATH, columns=["pcd", "ctry", "lat", "long"])
|
|
||||||
england = df.filter(pl.col("ctry") == "E92000001").drop_nulls(
|
|
||||||
subset=["lat", "long"]
|
|
||||||
)
|
|
||||||
return PostcodeSpatialIndex(
|
|
||||||
england.get_column("lat").to_list(),
|
|
||||||
england.get_column("long").to_list(),
|
|
||||||
england.get_column("pcd").to_list(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_postcode_coords() -> dict[str, tuple[float, float]]:
|
|
||||||
"""Build postcode → (lat, lng) lookup from arcgis England postcodes.
|
|
||||||
Used by OpenRent scraper to resolve coordinates from postcodes."""
|
|
||||||
log.info("Building postcode coords lookup from %s", ARCGIS_PATH)
|
|
||||||
df = pl.read_parquet(ARCGIS_PATH, columns=["pcd", "ctry", "lat", "long"])
|
|
||||||
england = df.filter(pl.col("ctry") == "E92000001").drop_nulls(
|
|
||||||
subset=["lat", "long"]
|
|
||||||
)
|
|
||||||
coords: dict[str, tuple[float, float]] = {}
|
|
||||||
for pcd, lat, lng in zip(
|
|
||||||
england.get_column("pcd").to_list(),
|
|
||||||
england.get_column("lat").to_list(),
|
|
||||||
england.get_column("long").to_list(),
|
|
||||||
):
|
|
||||||
coords[pcd] = (lat, lng)
|
|
||||||
log.info("Postcode coords lookup: %d postcodes", len(coords))
|
|
||||||
return coords
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_elapsed(seconds: float) -> str:
|
|
||||||
"""Format seconds as e.g. '2h13m' or '5m32s'."""
|
|
||||||
h, rem = divmod(int(seconds), 3600)
|
|
||||||
m, s = divmod(rem, 60)
|
|
||||||
if h:
|
|
||||||
return f"{h}h{m:02d}m"
|
|
||||||
return f"{m}m{s:02d}s"
|
|
||||||
|
|
||||||
|
|
||||||
def _dedup_key(p: dict) -> tuple:
|
|
||||||
"""Composite key for cross-source deduplication: (postcode, bedrooms, price).
|
|
||||||
Two listings on different portals for the same physical property will share
|
|
||||||
these attributes even though their IDs differ."""
|
|
||||||
return (p.get("Postcode", ""), p.get("Bedrooms", 0), p.get("price", 0))
|
|
||||||
|
|
||||||
|
|
||||||
class _Progress:
|
|
||||||
"""Thread-safe progress tracker for parallel source workers."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._counts: dict[str, int] = {}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def update(self, source: str, done: int) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._counts[source] = done
|
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, int]:
|
|
||||||
with self._lock:
|
|
||||||
return dict(self._counts)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_channel(
|
|
||||||
rm_props: list[dict],
|
|
||||||
hk_props: list[dict],
|
|
||||||
or_props: list[dict],
|
|
||||||
zp_props: list[dict],
|
|
||||||
) -> tuple[dict[str, dict], dict[str, int], int]:
|
|
||||||
"""Merge properties from all sources for one channel with cross-source dedup.
|
|
||||||
|
|
||||||
Rightmove has priority; other sources are checked for duplicates.
|
|
||||||
Returns (all_properties_by_id, per_source_counts, total_dedup_count).
|
|
||||||
"""
|
|
||||||
all_properties: dict[str, dict] = {}
|
|
||||||
seen_keys: set[tuple] = set()
|
|
||||||
counts = {"rm": 0, "hk": 0, "or": 0, "zp": 0}
|
|
||||||
total_dedup = 0
|
|
||||||
|
|
||||||
# Rightmove first (priority source)
|
|
||||||
for p in rm_props:
|
|
||||||
pid = p["id"]
|
|
||||||
if pid not in all_properties:
|
|
||||||
all_properties[pid] = p
|
|
||||||
seen_keys.add(_dedup_key(p))
|
|
||||||
counts["rm"] += 1
|
|
||||||
|
|
||||||
# Other sources (check for cross-source duplicates)
|
|
||||||
for source, props in [("hk", hk_props), ("or", or_props), ("zp", zp_props)]:
|
|
||||||
for p in props:
|
|
||||||
pid = p["id"]
|
|
||||||
key = _dedup_key(p)
|
|
||||||
if pid in all_properties or key in seen_keys:
|
|
||||||
total_dedup += 1
|
|
||||||
continue
|
|
||||||
all_properties[pid] = p
|
|
||||||
seen_keys.add(key)
|
|
||||||
counts[source] += 1
|
|
||||||
|
|
||||||
return all_properties, counts, total_dedup
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Checkpointing — save/resume partial results across crashes
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _checkpoint_meta_path():
|
|
||||||
return DATA_DIR / "checkpoint.json"
|
|
||||||
|
|
||||||
|
|
||||||
def _checkpoint_results_path(source: str, channel: str):
|
|
||||||
return DATA_DIR / f"checkpoint_{source}_{channel}.json"
|
|
||||||
|
|
||||||
|
|
||||||
def _save_checkpoint(
|
|
||||||
shuffled: list[str],
|
|
||||||
progress: _Progress,
|
|
||||||
source_results: dict[str, dict[str, list]],
|
|
||||||
active_sources: list[str],
|
|
||||||
) -> None:
|
|
||||||
"""Save per-source progress indices and partial results to disk.
|
|
||||||
|
|
||||||
Writes atomically (temp + rename) so a crash mid-write leaves the previous
|
|
||||||
checkpoint intact.
|
|
||||||
"""
|
|
||||||
snap = progress.snapshot()
|
|
||||||
|
|
||||||
meta = {
|
|
||||||
"seed": SEED,
|
|
||||||
"num_outcodes": len(shuffled),
|
|
||||||
"sources": {s: snap.get(s, 0) for s in active_sources},
|
|
||||||
"timestamp": time.time(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Write result files per source per channel
|
|
||||||
for source in active_sources:
|
|
||||||
results = source_results.get(source, {})
|
|
||||||
for ch_key in ("BUY", "RENT"):
|
|
||||||
props = results.get(ch_key, [])
|
|
||||||
path = _checkpoint_results_path(source, ch_key.lower())
|
|
||||||
tmp = path.with_suffix(".tmp")
|
|
||||||
try:
|
|
||||||
with open(tmp, "w") as f:
|
|
||||||
json.dump(props, f, default=str)
|
|
||||||
tmp.rename(path)
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("Failed to write checkpoint %s: %s", path.name, e)
|
|
||||||
|
|
||||||
# Write metadata atomically
|
|
||||||
tmp = _checkpoint_meta_path().with_suffix(".tmp")
|
|
||||||
try:
|
|
||||||
with open(tmp, "w") as f:
|
|
||||||
json.dump(meta, f)
|
|
||||||
tmp.rename(_checkpoint_meta_path())
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("Failed to write checkpoint metadata: %s", e)
|
|
||||||
return
|
|
||||||
|
|
||||||
total = sum(len(source_results.get(s, {}).get(ch, []))
|
|
||||||
for s in active_sources for ch in ("BUY", "RENT"))
|
|
||||||
log.info(
|
|
||||||
"Checkpoint saved: %s (%d properties)",
|
|
||||||
{s: snap.get(s, 0) for s in active_sources},
|
|
||||||
total,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_checkpoint(
|
|
||||||
shuffled: list[str],
|
|
||||||
) -> tuple[dict[str, int], dict[str, dict[str, list]]] | None:
|
|
||||||
"""Load checkpoint if it exists and matches the current outcode list.
|
|
||||||
|
|
||||||
Returns (start_indices, loaded_results) or None if no valid checkpoint.
|
|
||||||
"""
|
|
||||||
path = _checkpoint_meta_path()
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(path) as f:
|
|
||||||
meta = json.load(f)
|
|
||||||
except Exception:
|
|
||||||
log.warning("Checkpoint file corrupt, starting fresh")
|
|
||||||
_clear_checkpoint()
|
|
||||||
return None
|
|
||||||
|
|
||||||
if meta.get("seed") != SEED or meta.get("num_outcodes") != len(shuffled):
|
|
||||||
log.info("Checkpoint from different run configuration, discarding")
|
|
||||||
_clear_checkpoint()
|
|
||||||
return None
|
|
||||||
|
|
||||||
start_indices: dict[str, int] = {}
|
|
||||||
loaded_results: dict[str, dict[str, list]] = {}
|
|
||||||
|
|
||||||
for source, completed in meta.get("sources", {}).items():
|
|
||||||
start_indices[source] = completed
|
|
||||||
loaded_results[source] = {"BUY": [], "RENT": []}
|
|
||||||
for channel in ("buy", "rent"):
|
|
||||||
rpath = _checkpoint_results_path(source, channel)
|
|
||||||
if rpath.exists():
|
|
||||||
try:
|
|
||||||
with open(rpath) as f:
|
|
||||||
raw = json.load(f)
|
|
||||||
# Deduplicate by ID — concurrent workers (e.g. hk_worker's
|
|
||||||
# ThreadPoolExecutor) can cause in-flight outcodes to have
|
|
||||||
# results saved before their progress index is recorded.
|
|
||||||
# On resume those outcodes get re-scraped, duplicating results.
|
|
||||||
seen_ids: set[str] = set()
|
|
||||||
deduped: list[dict] = []
|
|
||||||
for p in raw:
|
|
||||||
pid = p.get("id")
|
|
||||||
if pid not in seen_ids:
|
|
||||||
seen_ids.add(pid)
|
|
||||||
deduped.append(p)
|
|
||||||
if len(deduped) < len(raw):
|
|
||||||
log.info(
|
|
||||||
"Checkpoint %s/%s: deduped %d → %d (removed %d dupes)",
|
|
||||||
source, channel, len(raw), len(deduped),
|
|
||||||
len(raw) - len(deduped),
|
|
||||||
)
|
|
||||||
loaded_results[source][channel.upper()] = deduped
|
|
||||||
except Exception:
|
|
||||||
log.warning(
|
|
||||||
"Checkpoint results for %s/%s corrupt, restarting %s",
|
|
||||||
source, channel, source,
|
|
||||||
)
|
|
||||||
start_indices[source] = 0
|
|
||||||
loaded_results[source] = {"BUY": [], "RENT": []}
|
|
||||||
break
|
|
||||||
|
|
||||||
elapsed_since = time.time() - meta.get("timestamp", 0)
|
|
||||||
log.info(
|
|
||||||
"Resuming from checkpoint (saved %.0fm ago): %s",
|
|
||||||
elapsed_since / 60,
|
|
||||||
start_indices,
|
|
||||||
)
|
|
||||||
return start_indices, loaded_results
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_checkpoint() -> None:
|
|
||||||
"""Remove all checkpoint files after successful completion."""
|
|
||||||
for path in DATA_DIR.glob("checkpoint*"):
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def run_scrape(
|
|
||||||
outcodes: list[str],
|
|
||||||
pc_index: PostcodeSpatialIndex,
|
|
||||||
pc_coords: dict[str, tuple[float, float]] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Main scrape orchestrator — runs all sources in parallel threads.
|
|
||||||
|
|
||||||
Each source (Rightmove, home.co.uk, OpenRent, Zoopla) gets its own thread
|
|
||||||
that iterates all outcodes for both BUY and RENT channels. Results are
|
|
||||||
merged with cross-source deduplication after all workers complete.
|
|
||||||
"""
|
|
||||||
global status
|
|
||||||
with status_lock:
|
|
||||||
status.state = "running"
|
|
||||||
status.started_at = time.time()
|
|
||||||
status.finished_at = 0.0
|
|
||||||
status.errors = []
|
|
||||||
status.properties_buy = 0
|
|
||||||
status.properties_rent = 0
|
|
||||||
status.channel = ""
|
|
||||||
status.outcode = ""
|
|
||||||
_sync_gauges()
|
|
||||||
|
|
||||||
shuffled = list(outcodes)
|
|
||||||
random.seed(SEED)
|
|
||||||
random.shuffle(shuffled)
|
|
||||||
|
|
||||||
if not any([SCRAPE_RIGHTMOVE, SCRAPE_HOMECOUK, SCRAPE_OPENRENT, SCRAPE_ZOOPLA]):
|
|
||||||
log.warning("All scrapers disabled — nothing to do")
|
|
||||||
with status_lock:
|
|
||||||
status.state = "done"
|
|
||||||
status.finished_at = time.time()
|
|
||||||
_sync_gauges()
|
|
||||||
return
|
|
||||||
|
|
||||||
if not SCRAPE_RIGHTMOVE:
|
|
||||||
log.info("Rightmove scraping DISABLED (SCRAPE_RIGHTMOVE=false)")
|
|
||||||
if not SCRAPE_HOMECOUK:
|
|
||||||
log.info("home.co.uk scraping DISABLED (SCRAPE_HOMECOUK=false)")
|
|
||||||
homecouk_enabled.set(0)
|
|
||||||
if not SCRAPE_OPENRENT:
|
|
||||||
log.info("OpenRent scraping DISABLED (SCRAPE_OPENRENT=false)")
|
|
||||||
openrent_enabled.set(0)
|
|
||||||
if not SCRAPE_ZOOPLA:
|
|
||||||
log.info("Zoopla scraping DISABLED (SCRAPE_ZOOPLA=false)")
|
|
||||||
zoopla_enabled.set(0)
|
|
||||||
|
|
||||||
# Build postcode coords if needed for OpenRent/Zoopla
|
|
||||||
if (SCRAPE_OPENRENT or SCRAPE_ZOOPLA) and pc_coords is None:
|
|
||||||
pc_coords = build_postcode_coords()
|
|
||||||
|
|
||||||
# Per-source result containers: {channel_name: [properties]}
|
|
||||||
# Each list is only written by its owning source thread.
|
|
||||||
rm_results: dict[str, list] = {"BUY": [], "RENT": []}
|
|
||||||
hk_results: dict[str, list] = {"BUY": [], "RENT": []}
|
|
||||||
or_results: dict[str, list] = {"BUY": [], "RENT": []}
|
|
||||||
zp_results: dict[str, list] = {"BUY": [], "RENT": []}
|
|
||||||
|
|
||||||
progress = _Progress()
|
|
||||||
|
|
||||||
# --- Resume from checkpoint if available ---
|
|
||||||
start_indices: dict[str, int] = {}
|
|
||||||
checkpoint = _load_checkpoint(shuffled)
|
|
||||||
if checkpoint:
|
|
||||||
start_indices, loaded = checkpoint
|
|
||||||
source_to_results = {"rm": rm_results, "hk": hk_results, "or": or_results, "zp": zp_results}
|
|
||||||
for src, data in loaded.items():
|
|
||||||
if src in source_to_results:
|
|
||||||
for ch in ("BUY", "RENT"):
|
|
||||||
source_to_results[src][ch] = data.get(ch, [])
|
|
||||||
# Reassign in case references changed
|
|
||||||
rm_results = source_to_results["rm"]
|
|
||||||
hk_results = source_to_results["hk"]
|
|
||||||
or_results = source_to_results["or"]
|
|
||||||
zp_results = source_to_results["zp"]
|
|
||||||
# Pre-set progress for resumed sources
|
|
||||||
for src, idx in start_indices.items():
|
|
||||||
if idx > 0:
|
|
||||||
progress.update(src, idx)
|
|
||||||
|
|
||||||
# --- Source worker closures ---
|
|
||||||
# Each worker owns its client lifecycle and iterates all outcodes for both
|
|
||||||
# channels. On auth failure, it refreshes cookies and continues. On fatal
|
|
||||||
# failure, it marks itself as done and returns partial results.
|
|
||||||
|
|
||||||
def rm_worker():
|
|
||||||
rm_start = start_indices.get("rm", 0)
|
|
||||||
if rm_start > 0:
|
|
||||||
log.info("Rightmove resuming from outcode %d/%d", rm_start, len(shuffled))
|
|
||||||
client = make_client()
|
|
||||||
try:
|
|
||||||
for i, outcode in enumerate(shuffled):
|
|
||||||
if i < rm_start:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
outcode_id = resolve_outcode_id(client, outcode)
|
|
||||||
except Exception as e:
|
|
||||||
log.error("Rightmove %s ID lookup: %s", outcode, e)
|
|
||||||
scrape_errors_total.labels(source="rightmove").inc()
|
|
||||||
progress.update("rm", i + 1)
|
|
||||||
time.sleep(DELAY_BETWEEN_OUTCODES)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not outcode_id:
|
|
||||||
log.debug("No Rightmove ID for %s, skipping", outcode)
|
|
||||||
progress.update("rm", i + 1)
|
|
||||||
time.sleep(DELAY_BETWEEN_OUTCODES)
|
|
||||||
continue
|
|
||||||
|
|
||||||
for ch_cfg in CHANNELS:
|
|
||||||
ch = ch_cfg["channel"]
|
|
||||||
try:
|
|
||||||
props = search_outcode(
|
|
||||||
client, outcode_id, outcode, ch_cfg, pc_index
|
|
||||||
)
|
|
||||||
rm_results[ch].extend(props)
|
|
||||||
except Exception as e:
|
|
||||||
log.error("Rightmove %s/%s: %s", outcode, ch, e)
|
|
||||||
scrape_errors_total.labels(source="rightmove").inc()
|
|
||||||
|
|
||||||
progress.update("rm", i + 1)
|
|
||||||
time.sleep(DELAY_BETWEEN_OUTCODES)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Fatal Rightmove error: %s", e)
|
|
||||||
with status_lock:
|
|
||||||
status.errors.append(f"Fatal Rightmove: {e}")
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
def hk_worker():
|
|
||||||
hk_result = load_homecouk_cookies()
|
|
||||||
if not hk_result:
|
|
||||||
log.info("home.co.uk DISABLED (no cookies available)")
|
|
||||||
homecouk_enabled.set(0)
|
|
||||||
progress.update("hk", len(shuffled))
|
|
||||||
return
|
|
||||||
hk_start = start_indices.get("hk", 0)
|
|
||||||
if hk_start > 0:
|
|
||||||
log.info("home.co.uk resuming from outcode %d/%d", hk_start, len(shuffled))
|
|
||||||
log.info(
|
|
||||||
"home.co.uk scraping ENABLED (concurrency=%d)", HOMECOUK_CONCURRENCY
|
|
||||||
)
|
|
||||||
homecouk_enabled.set(1)
|
|
||||||
|
|
||||||
# Shared state across pool threads
|
|
||||||
cookie_state = {
|
|
||||||
"cookies": hk_result[0],
|
|
||||||
"user_agent": hk_result[1],
|
|
||||||
"generation": 0,
|
|
||||||
}
|
|
||||||
cookie_lock = threading.Lock()
|
|
||||||
results_lock = threading.Lock()
|
|
||||||
completed_count = [hk_start]
|
|
||||||
disabled = [False]
|
|
||||||
_local = threading.local()
|
|
||||||
|
|
||||||
def _get_client():
|
|
||||||
"""Get or create a thread-local curl_cffi session."""
|
|
||||||
with cookie_lock:
|
|
||||||
gen = cookie_state["generation"]
|
|
||||||
cookies = cookie_state["cookies"]
|
|
||||||
ua = cookie_state["user_agent"]
|
|
||||||
if not hasattr(_local, "client") or _local.gen != gen:
|
|
||||||
if hasattr(_local, "client"):
|
|
||||||
try:
|
|
||||||
_local.client.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_local.client = make_homecouk_client(cookies, ua)
|
|
||||||
_local.gen = gen
|
|
||||||
return _local.client
|
|
||||||
|
|
||||||
def _refresh_cookies():
|
|
||||||
"""Refresh cookies via FlareSolverr. Thread-safe with generation check."""
|
|
||||||
with cookie_lock:
|
|
||||||
pre_gen = cookie_state["generation"]
|
|
||||||
new = load_homecouk_cookies()
|
|
||||||
if not new:
|
|
||||||
return False
|
|
||||||
with cookie_lock:
|
|
||||||
if cookie_state["generation"] == pre_gen:
|
|
||||||
cookie_state["cookies"] = new[0]
|
|
||||||
cookie_state["user_agent"] = new[1]
|
|
||||||
cookie_state["generation"] += 1
|
|
||||||
cookie_refreshes_total.labels(result="success").inc()
|
|
||||||
log.info("home.co.uk cookies refreshed")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _scrape_outcode(outcode):
|
|
||||||
if disabled[0]:
|
|
||||||
return
|
|
||||||
client = _get_client()
|
|
||||||
for ch_cfg in CHANNELS:
|
|
||||||
ch = ch_cfg["channel"]
|
|
||||||
if disabled[0]:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
props = homecouk_search_outcode(
|
|
||||||
client, outcode, ch, pc_index
|
|
||||||
)
|
|
||||||
if props:
|
|
||||||
with results_lock:
|
|
||||||
hk_results[ch].extend(props)
|
|
||||||
log.info(
|
|
||||||
"home.co.uk %s: +%d properties", outcode, len(props)
|
|
||||||
)
|
|
||||||
except CookiesExpiredError:
|
|
||||||
log.warning(
|
|
||||||
"home.co.uk cookies expired — attempting refresh"
|
|
||||||
)
|
|
||||||
if _refresh_cookies():
|
|
||||||
client = _get_client()
|
|
||||||
try:
|
|
||||||
props = homecouk_search_outcode(
|
|
||||||
client, outcode, ch, pc_index
|
|
||||||
)
|
|
||||||
if props:
|
|
||||||
with results_lock:
|
|
||||||
hk_results[ch].extend(props)
|
|
||||||
log.info(
|
|
||||||
"home.co.uk %s: +%d properties",
|
|
||||||
outcode,
|
|
||||||
len(props),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log.error(
|
|
||||||
"home.co.uk %s/%s (after refresh): %s",
|
|
||||||
outcode,
|
|
||||||
ch,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
scrape_errors_total.labels(source="homecouk").inc()
|
|
||||||
else:
|
|
||||||
log.warning(
|
|
||||||
"Cookie refresh failed, disabling home.co.uk"
|
|
||||||
)
|
|
||||||
disabled[0] = True
|
|
||||||
homecouk_enabled.set(0)
|
|
||||||
cookie_refreshes_total.labels(result="failure").inc()
|
|
||||||
with status_lock:
|
|
||||||
status.errors.append(
|
|
||||||
"home.co.uk cookies expired and refresh failed"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
log.error("home.co.uk %s/%s: %s", outcode, ch, e)
|
|
||||||
scrape_errors_total.labels(source="homecouk").inc()
|
|
||||||
|
|
||||||
with results_lock:
|
|
||||||
completed_count[0] += 1
|
|
||||||
progress.update("hk", completed_count[0])
|
|
||||||
time.sleep(DELAY_BETWEEN_OUTCODES)
|
|
||||||
|
|
||||||
try:
|
|
||||||
work = [oc for i, oc in enumerate(shuffled) if i >= hk_start]
|
|
||||||
with ThreadPoolExecutor(
|
|
||||||
max_workers=HOMECOUK_CONCURRENCY
|
|
||||||
) as pool:
|
|
||||||
list(pool.map(_scrape_outcode, work))
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Fatal home.co.uk error: %s", e)
|
|
||||||
with status_lock:
|
|
||||||
status.errors.append(f"Fatal home.co.uk: {e}")
|
|
||||||
|
|
||||||
if disabled[0]:
|
|
||||||
progress.update("hk", len(shuffled))
|
|
||||||
|
|
||||||
def or_worker():
|
|
||||||
or_result = load_openrent_cookies()
|
|
||||||
if not or_result:
|
|
||||||
log.info("OpenRent DISABLED (no cookies available)")
|
|
||||||
openrent_enabled.set(0)
|
|
||||||
progress.update("or", len(shuffled))
|
|
||||||
return
|
|
||||||
or_start = start_indices.get("or", 0)
|
|
||||||
if or_start > 0:
|
|
||||||
log.info("OpenRent resuming from outcode %d/%d", or_start, len(shuffled))
|
|
||||||
client = make_openrent_client(*or_result)
|
|
||||||
log.info("OpenRent scraping ENABLED")
|
|
||||||
openrent_enabled.set(1)
|
|
||||||
try:
|
|
||||||
for i, outcode in enumerate(shuffled):
|
|
||||||
if i < or_start:
|
|
||||||
continue
|
|
||||||
# OpenRent is RENT-only
|
|
||||||
try:
|
|
||||||
props = openrent_search_outcode(
|
|
||||||
client, outcode, pc_index, pc_coords
|
|
||||||
)
|
|
||||||
or_results["RENT"].extend(props)
|
|
||||||
if props:
|
|
||||||
log.info("OpenRent %s: +%d properties", outcode, len(props))
|
|
||||||
except WafChallengeError:
|
|
||||||
log.warning(
|
|
||||||
"OpenRent WAF cookies expired — attempting refresh"
|
|
||||||
)
|
|
||||||
client.close()
|
|
||||||
or_new = load_openrent_cookies()
|
|
||||||
if or_new:
|
|
||||||
client = make_openrent_client(*or_new)
|
|
||||||
log.info("OpenRent cookies refreshed, continuing")
|
|
||||||
cookie_refreshes_total.labels(result="success").inc()
|
|
||||||
else:
|
|
||||||
log.warning(
|
|
||||||
"Cookie refresh failed, disabling OpenRent"
|
|
||||||
)
|
|
||||||
openrent_enabled.set(0)
|
|
||||||
cookie_refreshes_total.labels(result="failure").inc()
|
|
||||||
with status_lock:
|
|
||||||
status.errors.append(
|
|
||||||
"OpenRent WAF cookies expired and refresh failed"
|
|
||||||
)
|
|
||||||
progress.update("or", len(shuffled))
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
log.error("OpenRent %s: %s", outcode, e)
|
|
||||||
scrape_errors_total.labels(source="openrent").inc()
|
|
||||||
|
|
||||||
progress.update("or", i + 1)
|
|
||||||
time.sleep(DELAY_BETWEEN_OUTCODES)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Fatal OpenRent error: %s", e)
|
|
||||||
with status_lock:
|
|
||||||
status.errors.append(f"Fatal OpenRent: {e}")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
client.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def zp_worker():
|
|
||||||
try:
|
|
||||||
browser, page = launch_zoopla_browser()
|
|
||||||
log.info("Zoopla scraping ENABLED (Camoufox browser launched)")
|
|
||||||
zoopla_enabled.set(1)
|
|
||||||
except TurnstileError:
|
|
||||||
log.warning("Zoopla Cloudflare Turnstile failed — disabling Zoopla")
|
|
||||||
zoopla_enabled.set(0)
|
|
||||||
progress.update("zp", len(shuffled))
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("Zoopla browser launch failed: %s — disabling Zoopla", e)
|
|
||||||
zoopla_enabled.set(0)
|
|
||||||
progress.update("zp", len(shuffled))
|
|
||||||
return
|
|
||||||
|
|
||||||
zp_start = start_indices.get("zp", 0)
|
|
||||||
if zp_start > 0:
|
|
||||||
log.info("Zoopla resuming from outcode %d/%d", zp_start, len(shuffled))
|
|
||||||
|
|
||||||
try:
|
|
||||||
for i, outcode in enumerate(shuffled):
|
|
||||||
if i < zp_start:
|
|
||||||
continue
|
|
||||||
search_url = None
|
|
||||||
for ch_cfg in CHANNELS:
|
|
||||||
ch = ch_cfg["channel"]
|
|
||||||
# Build direct URL for second channel by swapping path
|
|
||||||
direct_url = None
|
|
||||||
if search_url:
|
|
||||||
if ch == "BUY":
|
|
||||||
direct_url = search_url.replace("/to-rent/", "/for-sale/")
|
|
||||||
else:
|
|
||||||
direct_url = search_url.replace("/for-sale/", "/to-rent/")
|
|
||||||
try:
|
|
||||||
props, result_url = zoopla_search_outcode(
|
|
||||||
page, outcode, ch, pc_index, pc_coords,
|
|
||||||
base_search_url=direct_url,
|
|
||||||
)
|
|
||||||
if result_url:
|
|
||||||
search_url = result_url
|
|
||||||
zp_results[ch].extend(props)
|
|
||||||
if props:
|
|
||||||
log.info("Zoopla %s: +%d properties", outcode, len(props))
|
|
||||||
except TurnstileError:
|
|
||||||
log.warning(
|
|
||||||
"Zoopla Turnstile challenge — relaunching browser"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
browser.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
browser, page = launch_zoopla_browser()
|
|
||||||
log.info("Zoopla browser relaunched, continuing")
|
|
||||||
except Exception:
|
|
||||||
log.warning(
|
|
||||||
"Browser relaunch failed, disabling Zoopla"
|
|
||||||
)
|
|
||||||
zoopla_enabled.set(0)
|
|
||||||
with status_lock:
|
|
||||||
status.errors.append(
|
|
||||||
"Zoopla Cloudflare challenge failed and relaunch failed"
|
|
||||||
)
|
|
||||||
progress.update("zp", len(shuffled))
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
log.error("Zoopla %s/%s: %s", outcode, ch, e)
|
|
||||||
scrape_errors_total.labels(source="zoopla").inc()
|
|
||||||
|
|
||||||
progress.update("zp", i + 1)
|
|
||||||
time.sleep(DELAY_BETWEEN_OUTCODES)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Fatal Zoopla error: %s", e)
|
|
||||||
with status_lock:
|
|
||||||
status.errors.append(f"Fatal Zoopla: {e}")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
browser.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# --- Launch worker threads ---
|
|
||||||
|
|
||||||
active_sources: list[str] = []
|
|
||||||
threads: list[threading.Thread] = []
|
|
||||||
|
|
||||||
if SCRAPE_RIGHTMOVE:
|
|
||||||
threads.append(threading.Thread(target=rm_worker, name="scrape-rm", daemon=True))
|
|
||||||
active_sources.append("rm")
|
|
||||||
if SCRAPE_HOMECOUK:
|
|
||||||
threads.append(threading.Thread(target=hk_worker, name="scrape-hk", daemon=True))
|
|
||||||
active_sources.append("hk")
|
|
||||||
if SCRAPE_OPENRENT:
|
|
||||||
threads.append(threading.Thread(target=or_worker, name="scrape-or", daemon=True))
|
|
||||||
active_sources.append("or")
|
|
||||||
if SCRAPE_ZOOPLA:
|
|
||||||
threads.append(threading.Thread(target=zp_worker, name="scrape-zp", daemon=True))
|
|
||||||
active_sources.append("zp")
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"=== Starting scrape: %d outcodes, sources: %s ===",
|
|
||||||
len(shuffled),
|
|
||||||
", ".join(active_sources),
|
|
||||||
)
|
|
||||||
|
|
||||||
for t in threads:
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
# --- Monitor progress while workers run ---
|
|
||||||
|
|
||||||
# Map source names to result dicts for checkpointing
|
|
||||||
source_results_map = {
|
|
||||||
"rm": rm_results, "hk": hk_results,
|
|
||||||
"or": or_results, "zp": zp_results,
|
|
||||||
}
|
|
||||||
|
|
||||||
scrape_start = time.time()
|
|
||||||
last_log = 0.0
|
|
||||||
last_checkpoint = time.time()
|
|
||||||
|
|
||||||
try:
|
|
||||||
while any(t.is_alive() for t in threads):
|
|
||||||
snap = progress.snapshot()
|
|
||||||
min_done = min(
|
|
||||||
(snap.get(s, 0) for s in active_sources), default=0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Count properties across sources (safe: only one thread writes each list)
|
|
||||||
total_buy = sum(
|
|
||||||
len(r["BUY"]) for r in [rm_results, hk_results, or_results, zp_results]
|
|
||||||
)
|
|
||||||
total_rent = sum(
|
|
||||||
len(r["RENT"]) for r in [rm_results, hk_results, or_results, zp_results]
|
|
||||||
)
|
|
||||||
|
|
||||||
with status_lock:
|
|
||||||
status.outcodes_done = min_done
|
|
||||||
status.outcodes_total = len(shuffled)
|
|
||||||
status.properties_buy = total_buy
|
|
||||||
status.properties_rent = total_rent
|
|
||||||
status.rm_properties = len(rm_results["BUY"]) + len(rm_results["RENT"])
|
|
||||||
status.hk_properties = len(hk_results["BUY"]) + len(hk_results["RENT"])
|
|
||||||
status.or_properties = len(or_results["RENT"])
|
|
||||||
status.zp_properties = len(zp_results["BUY"]) + len(zp_results["RENT"])
|
|
||||||
_sync_gauges()
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
# Log progress every 30 seconds
|
|
||||||
if now - last_log >= 30:
|
|
||||||
elapsed = now - scrape_start
|
|
||||||
per_source = ", ".join(
|
|
||||||
f"{s}:{snap.get(s, 0)}" for s in active_sources
|
|
||||||
)
|
|
||||||
log.info(
|
|
||||||
"Progress: %d/%d outcodes (%s), %d buy + %d rent props, %s elapsed",
|
|
||||||
min_done,
|
|
||||||
len(shuffled),
|
|
||||||
per_source,
|
|
||||||
total_buy,
|
|
||||||
total_rent,
|
|
||||||
_fmt_elapsed(elapsed),
|
|
||||||
)
|
|
||||||
last_log = now
|
|
||||||
|
|
||||||
# Save checkpoint periodically
|
|
||||||
if now - last_checkpoint >= CHECKPOINT_INTERVAL:
|
|
||||||
try:
|
|
||||||
_save_checkpoint(
|
|
||||||
shuffled, progress, source_results_map, active_sources,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("Checkpoint save failed: %s", e)
|
|
||||||
last_checkpoint = now
|
|
||||||
|
|
||||||
time.sleep(5)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Monitor loop error: %s", e)
|
|
||||||
|
|
||||||
# Save final checkpoint before joining (in case merge/write fails)
|
|
||||||
try:
|
|
||||||
_save_checkpoint(shuffled, progress, source_results_map, active_sources)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for t in threads:
|
|
||||||
t.join()
|
|
||||||
|
|
||||||
log.info("All source workers completed")
|
|
||||||
|
|
||||||
# --- Merge results per channel and write parquet ---
|
|
||||||
|
|
||||||
try:
|
|
||||||
for ch_cfg in CHANNELS:
|
|
||||||
ch = ch_cfg["channel"]
|
|
||||||
file_suffix = "buy" if ch == "BUY" else "rent"
|
|
||||||
|
|
||||||
merged, counts, total_dedup = _merge_channel(
|
|
||||||
rm_results[ch],
|
|
||||||
hk_results[ch],
|
|
||||||
or_results[ch],
|
|
||||||
zp_results[ch],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update cross-source dedup counter
|
|
||||||
ch_label = "buy" if ch == "BUY" else "rent"
|
|
||||||
if total_dedup:
|
|
||||||
cross_source_dedup_total.labels(channel=ch_label).inc(total_dedup)
|
|
||||||
|
|
||||||
deduped = list(merged.values())
|
|
||||||
output_path = DATA_DIR / f"online_listings_{file_suffix}.parquet"
|
|
||||||
write_parquet(deduped, output_path, channel=file_suffix)
|
|
||||||
|
|
||||||
with status_lock:
|
|
||||||
if ch == "BUY":
|
|
||||||
status.properties_buy = len(deduped)
|
|
||||||
else:
|
|
||||||
status.properties_rent = len(deduped)
|
|
||||||
_sync_gauges()
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"=== %s complete: %d unique (rm:%d hk:%d or:%d zp:%d, cross-dedup:%d) ===",
|
|
||||||
ch,
|
|
||||||
len(deduped),
|
|
||||||
counts["rm"],
|
|
||||||
counts["hk"],
|
|
||||||
counts["or"],
|
|
||||||
counts["zp"],
|
|
||||||
total_dedup,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Scrape completed successfully — clear checkpoint
|
|
||||||
_clear_checkpoint()
|
|
||||||
|
|
||||||
with status_lock:
|
|
||||||
status.state = "done"
|
|
||||||
status.finished_at = time.time()
|
|
||||||
status.outcodes_done = len(shuffled)
|
|
||||||
_sync_gauges()
|
|
||||||
elapsed = status.finished_at - status.started_at
|
|
||||||
log.info(
|
|
||||||
"Scrape complete in %s — buy: %d, rent: %d",
|
|
||||||
_fmt_elapsed(elapsed),
|
|
||||||
status.properties_buy,
|
|
||||||
status.properties_rent,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Trigger server data reload
|
|
||||||
if RELOAD_URL:
|
|
||||||
try:
|
|
||||||
log.info("Triggering server reload at %s", RELOAD_URL)
|
|
||||||
resp = httpx.post(RELOAD_URL, timeout=300)
|
|
||||||
if resp.is_success:
|
|
||||||
body = resp.json()
|
|
||||||
log.info(
|
|
||||||
"Server reload complete: %d rows, %d features, %dms",
|
|
||||||
body.get("rows", 0),
|
|
||||||
body.get("features", 0),
|
|
||||||
body.get("elapsed_ms", 0),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.warning(
|
|
||||||
"Server reload failed (%d): %s",
|
|
||||||
resp.status_code,
|
|
||||||
resp.text[:200],
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("Server reload request failed: %s", e)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Fatal scrape error during merge/write")
|
|
||||||
with status_lock:
|
|
||||||
status.state = "error"
|
|
||||||
status.errors.append(f"Fatal: {e}")
|
|
||||||
status.finished_at = time.time()
|
|
||||||
_sync_gauges()
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
from constants import GRID_CELL_SIZE
|
|
||||||
|
|
||||||
log = logging.getLogger("rightmove")
|
|
||||||
|
|
||||||
|
|
||||||
class PostcodeSpatialIndex:
|
|
||||||
"""Grid-based spatial index over arcgis postcodes for nearest-lookup."""
|
|
||||||
|
|
||||||
def __init__(self, lats: list[float], lngs: list[float], postcodes: list[str]):
|
|
||||||
self.grid: dict[tuple[int, int], list[tuple[float, float, str]]] = defaultdict(
|
|
||||||
list
|
|
||||||
)
|
|
||||||
for lat, lng, pcd in zip(lats, lngs, postcodes):
|
|
||||||
gx = int(math.floor(lng / GRID_CELL_SIZE))
|
|
||||||
gy = int(math.floor(lat / GRID_CELL_SIZE))
|
|
||||||
self.grid[(gx, gy)].append((lat, lng, pcd))
|
|
||||||
log.info(
|
|
||||||
"Postcode spatial index: %d cells, %d postcodes", len(self.grid), len(lats)
|
|
||||||
)
|
|
||||||
|
|
||||||
def nearest(self, lat: float, lng: float) -> str | None:
|
|
||||||
gx = int(math.floor(lng / GRID_CELL_SIZE))
|
|
||||||
gy = int(math.floor(lat / GRID_CELL_SIZE))
|
|
||||||
best_dist = float("inf")
|
|
||||||
best_pcd = None
|
|
||||||
for dx in range(-1, 2):
|
|
||||||
for dy in range(-1, 2):
|
|
||||||
for plat, plng, pcd in self.grid.get((gx + dx, gy + dy), []):
|
|
||||||
d = (plat - lat) ** 2 + (plng - lng) ** 2
|
|
||||||
if d < best_dist:
|
|
||||||
best_dist = d
|
|
||||||
best_pcd = pcd
|
|
||||||
return best_pcd
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
from constants import MAX_BEDROOMS, MAX_RENT_MONTHLY, MIN_RENT_MONTHLY
|
|
||||||
from transform import map_property_type, normalize_postcode, normalize_price
|
|
||||||
|
|
||||||
log = logging.getLogger("rightmove")
|
|
||||||
|
|
||||||
|
|
||||||
def write_parquet(properties: list[dict], path: Path, channel: str) -> None:
|
|
||||||
"""Write properties list to parquet with server-ready column names.
|
|
||||||
|
|
||||||
channel: "buy" or "rent"
|
|
||||||
"""
|
|
||||||
if not properties:
|
|
||||||
log.warning("No properties to write to %s", path)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Sanitize bedroom/bathroom counts — values above MAX_BEDROOMS are
|
|
||||||
# almost certainly prices or other numeric fields mis-parsed as bedrooms.
|
|
||||||
bad_count = 0
|
|
||||||
for p in properties:
|
|
||||||
for key in ("Bedrooms", "Bathrooms"):
|
|
||||||
val = p.get(key, 0) or 0
|
|
||||||
if val > MAX_BEDROOMS:
|
|
||||||
bad_count += 1
|
|
||||||
p[key] = None
|
|
||||||
# Recompute derived field after sanitization
|
|
||||||
beds = p.get("Bedrooms")
|
|
||||||
baths = p.get("Bathrooms")
|
|
||||||
if beds is None or baths is None:
|
|
||||||
p["Number of bedrooms & living rooms"] = None
|
|
||||||
else:
|
|
||||||
p["Number of bedrooms & living rooms"] = beds + baths
|
|
||||||
|
|
||||||
if bad_count:
|
|
||||||
log.warning(
|
|
||||||
"Sanitized %d properties with bedroom/bathroom counts > %d (set to null)",
|
|
||||||
bad_count,
|
|
||||||
MAX_BEDROOMS,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Re-derive Property type from Property sub-type using current PROPERTY_TYPE_MAP.
|
|
||||||
# This retroactively fixes data scraped with older versions of the type map.
|
|
||||||
remapped = 0
|
|
||||||
for p in properties:
|
|
||||||
sub_type = p.get("Property sub-type", "")
|
|
||||||
if sub_type and sub_type != "Unknown":
|
|
||||||
new_type = map_property_type(sub_type)
|
|
||||||
if new_type != p.get("Property type"):
|
|
||||||
p["Property type"] = new_type
|
|
||||||
remapped += 1
|
|
||||||
if remapped:
|
|
||||||
log.info("Re-mapped %d property types from sub-types", remapped)
|
|
||||||
|
|
||||||
# Parse first_visible_date to datetime
|
|
||||||
listing_dates = []
|
|
||||||
for p in properties:
|
|
||||||
fvd = p.get("first_visible_date", "")
|
|
||||||
if fvd:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(fvd.replace("Z", "+00:00"))
|
|
||||||
# Convert to UTC naive datetime for consistent storage
|
|
||||||
if dt.tzinfo is not None:
|
|
||||||
from datetime import timezone
|
|
||||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
||||||
listing_dates.append(dt)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
# Try additional date formats (OpenRent: "DD Month, YYYY", "Today")
|
|
||||||
parsed = None
|
|
||||||
stripped = fvd.strip()
|
|
||||||
lower = stripped.lower()
|
|
||||||
if lower == "today":
|
|
||||||
parsed = datetime.now().replace(
|
|
||||||
hour=0, minute=0, second=0, microsecond=0
|
|
||||||
)
|
|
||||||
elif lower == "tomorrow":
|
|
||||||
from datetime import timedelta
|
|
||||||
parsed = (
|
|
||||||
datetime.now() + timedelta(days=1)
|
|
||||||
).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
else:
|
|
||||||
for fmt in ("%d %B, %Y", "%d %B %Y"):
|
|
||||||
try:
|
|
||||||
parsed = datetime.strptime(stripped, fmt)
|
|
||||||
break
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
listing_dates.append(parsed)
|
|
||||||
else:
|
|
||||||
listing_dates.append(None)
|
|
||||||
|
|
||||||
# Derive asking price / asking rent based on channel
|
|
||||||
# Zero prices indicate parsing failures or POA/auction listings — treat as null
|
|
||||||
if channel == "buy":
|
|
||||||
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
|
|
||||||
asking_rents = [None] * len(properties)
|
|
||||||
listing_statuses = ["For sale"] * len(properties)
|
|
||||||
else:
|
|
||||||
asking_prices = [None] * len(properties)
|
|
||||||
# Normalize to monthly, then apply sanity bounds. Rents outside
|
|
||||||
# [MIN_RENT_MONTHLY, MAX_RENT_MONTHLY] are almost always total-stay
|
|
||||||
# pricing (short lets), annual rents mislabelled as monthly, or £0
|
|
||||||
# placeholders — null them out rather than polluting aggregates.
|
|
||||||
rent_outliers = 0
|
|
||||||
asking_rents = []
|
|
||||||
for p in properties:
|
|
||||||
monthly = normalize_price(p["price"], p["price_frequency"])
|
|
||||||
if monthly < MIN_RENT_MONTHLY or monthly > MAX_RENT_MONTHLY:
|
|
||||||
rent_outliers += 1
|
|
||||||
asking_rents.append(None)
|
|
||||||
else:
|
|
||||||
asking_rents.append(monthly)
|
|
||||||
if rent_outliers:
|
|
||||||
log.warning(
|
|
||||||
"Nulled %d rent outliers outside [£%d, £%d]/month",
|
|
||||||
rent_outliers,
|
|
||||||
MIN_RENT_MONTHLY,
|
|
||||||
MAX_RENT_MONTHLY,
|
|
||||||
)
|
|
||||||
listing_statuses = ["For rent"] * len(properties)
|
|
||||||
|
|
||||||
df = pl.DataFrame(
|
|
||||||
{
|
|
||||||
"Bedrooms": [p["Bedrooms"] for p in properties],
|
|
||||||
"Bathrooms": [p["Bathrooms"] for p in properties],
|
|
||||||
"Number of bedrooms & living rooms": [
|
|
||||||
p["Number of bedrooms & living rooms"] for p in properties
|
|
||||||
],
|
|
||||||
"lon": [p["lon"] for p in properties],
|
|
||||||
"lat": [p["lat"] for p in properties],
|
|
||||||
"Postcode": [normalize_postcode(p["Postcode"]) for p in properties],
|
|
||||||
"Address per Property Register": [
|
|
||||||
p["Address per Property Register"] for p in properties
|
|
||||||
],
|
|
||||||
"Leasehold/Freehold": [p["Leasehold/Freehold"] for p in properties],
|
|
||||||
"Property type": [p["Property type"] for p in properties],
|
|
||||||
"Property sub-type": [p["Property sub-type"] for p in properties],
|
|
||||||
"Price qualifier": [p["Price qualifier"] for p in properties],
|
|
||||||
"Total floor area (sqm)": [p["Total floor area (sqm)"] for p in properties],
|
|
||||||
"Listing URL": [p["Listing URL"] for p in properties],
|
|
||||||
"Listing features": [p["Listing features"] for p in properties],
|
|
||||||
"Listing date": listing_dates,
|
|
||||||
"Listing status": listing_statuses,
|
|
||||||
"Asking price": asking_prices,
|
|
||||||
"Asking rent (monthly)": asking_rents,
|
|
||||||
},
|
|
||||||
schema={
|
|
||||||
"Bedrooms": pl.Int32,
|
|
||||||
"Bathrooms": pl.Int32,
|
|
||||||
"Number of bedrooms & living rooms": pl.Int32,
|
|
||||||
"lon": pl.Float64,
|
|
||||||
"lat": pl.Float64,
|
|
||||||
"Postcode": pl.Utf8,
|
|
||||||
"Address per Property Register": pl.Utf8,
|
|
||||||
"Leasehold/Freehold": pl.Utf8,
|
|
||||||
"Property type": pl.Utf8,
|
|
||||||
"Property sub-type": pl.Utf8,
|
|
||||||
"Price qualifier": pl.Utf8,
|
|
||||||
"Total floor area (sqm)": pl.Float64,
|
|
||||||
"Listing URL": pl.Utf8,
|
|
||||||
"Listing features": pl.List(pl.Utf8),
|
|
||||||
"Listing date": pl.Datetime("us"),
|
|
||||||
"Listing status": pl.Utf8,
|
|
||||||
"Asking price": pl.Int64,
|
|
||||||
"Asking rent (monthly)": pl.Int64,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Derive asking price per sqm for buy listings
|
|
||||||
if channel == "buy":
|
|
||||||
df = df.with_columns(
|
|
||||||
(pl.col("Asking price") / pl.col("Total floor area (sqm)"))
|
|
||||||
.round(0)
|
|
||||||
.cast(pl.Int32, strict=False)
|
|
||||||
.alias("Asking price per sqm"),
|
|
||||||
)
|
|
||||||
|
|
||||||
df.write_parquet(path)
|
|
||||||
log.info("Wrote %d properties to %s", len(df), path)
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
|
|
||||||
from constants import MAX_BEDROOMS, PROPERTY_TYPE_MAP, RIGHTMOVE_BASE
|
|
||||||
from spatial import PostcodeSpatialIndex
|
|
||||||
|
|
||||||
log = logging.getLogger("rightmove")
|
|
||||||
|
|
||||||
|
|
||||||
# Floor area bounds (sqm). Values outside this range are almost certainly
|
|
||||||
# data errors: sub-5 sqm catches garbled extractions (e.g., 0.1 sqm for a
|
|
||||||
# detached house), and >2000 sqm (~21,500 sq ft) exceeds even the largest
|
|
||||||
# UK mansions.
|
|
||||||
MIN_FLOOR_AREA_SQM = 5.0
|
|
||||||
MAX_FLOOR_AREA_SQM = 2000.0
|
|
||||||
|
|
||||||
|
|
||||||
def validate_floor_area(sqm: float | None) -> float | None:
|
|
||||||
"""Validate a floor area value. Returns None for nonsensical values.
|
|
||||||
|
|
||||||
Rejects values below MIN_FLOOR_AREA_SQM and above MAX_FLOOR_AREA_SQM,
|
|
||||||
which catches parsing errors where prices or other large numbers are
|
|
||||||
mistakenly extracted as floor area from free-text descriptions or DOM text.
|
|
||||||
"""
|
|
||||||
if sqm is None:
|
|
||||||
return None
|
|
||||||
if sqm < MIN_FLOOR_AREA_SQM or sqm > MAX_FLOOR_AREA_SQM:
|
|
||||||
return None
|
|
||||||
return sqm
|
|
||||||
|
|
||||||
|
|
||||||
def parse_display_size(display_size: str | None) -> float | None:
|
|
||||||
"""Parse displaySize like '499 sq. ft.' or '4,124 sq. ft.' to sqm."""
|
|
||||||
if not display_size:
|
|
||||||
return None
|
|
||||||
# Try sq. ft. first
|
|
||||||
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*ft", display_size, re.IGNORECASE)
|
|
||||||
if m:
|
|
||||||
sqft = float(m.group(1).replace(",", ""))
|
|
||||||
return validate_floor_area(round(sqft * 0.092903, 1))
|
|
||||||
# Try sq. m.
|
|
||||||
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*m", display_size, re.IGNORECASE)
|
|
||||||
if m:
|
|
||||||
return validate_floor_area(round(float(m.group(1).replace(",", "")), 1))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_sub_type(sub_type: str | None) -> str:
|
|
||||||
"""Normalize property sub-type for consistent storage.
|
|
||||||
|
|
||||||
Fixes delimiter inconsistencies (underscores/hyphens → spaces) from
|
|
||||||
home.co.uk and truncates Zoopla description fragments that were
|
|
||||||
accidentally captured as sub-types.
|
|
||||||
"""
|
|
||||||
if not sub_type:
|
|
||||||
return "Unknown"
|
|
||||||
cleaned = sub_type.replace("_", " ").strip()
|
|
||||||
# Description fragments captured as sub-types are much longer than any
|
|
||||||
# real property type name (longest canonical is ~25 chars)
|
|
||||||
if len(cleaned) > 40:
|
|
||||||
return "Unknown"
|
|
||||||
# Collapse multiple spaces
|
|
||||||
cleaned = re.sub(r"\s+", " ", cleaned)
|
|
||||||
return cleaned.title()
|
|
||||||
|
|
||||||
|
|
||||||
def map_property_type(sub_type: str | None) -> str:
|
|
||||||
"""Map propertySubType to canonical type."""
|
|
||||||
if not sub_type:
|
|
||||||
return "Other"
|
|
||||||
canonical = PROPERTY_TYPE_MAP.get(sub_type)
|
|
||||||
if canonical:
|
|
||||||
return canonical
|
|
||||||
# Try title-case variant (e.g., "country house" → "Country House")
|
|
||||||
canonical = PROPERTY_TYPE_MAP.get(sub_type.title())
|
|
||||||
if canonical:
|
|
||||||
return canonical
|
|
||||||
# Try lowercase variant (e.g., "Townhouse" → "townhouse")
|
|
||||||
canonical = PROPERTY_TYPE_MAP.get(sub_type.lower())
|
|
||||||
if canonical:
|
|
||||||
return canonical
|
|
||||||
# Normalize delimiters (underscores/hyphens → spaces) and try again
|
|
||||||
normalized = re.sub(r"[-_]+", " ", sub_type).strip().title()
|
|
||||||
canonical = PROPERTY_TYPE_MAP.get(normalized)
|
|
||||||
if canonical:
|
|
||||||
return canonical
|
|
||||||
# Keyword fallback for compound types not in the map
|
|
||||||
lower = sub_type.lower()
|
|
||||||
if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower:
|
|
||||||
return "Flats/Maisonettes"
|
|
||||||
if "semi" in lower and "detach" in lower:
|
|
||||||
return "Semi-Detached"
|
|
||||||
if "detach" in lower:
|
|
||||||
return "Detached"
|
|
||||||
if "terrace" in lower or "mews" in lower:
|
|
||||||
return "Terraced"
|
|
||||||
if "house" in lower or "cottage" in lower:
|
|
||||||
return "Detached"
|
|
||||||
log.warning("Unknown propertySubType: %r — mapping to Other", sub_type)
|
|
||||||
return "Other"
|
|
||||||
|
|
||||||
|
|
||||||
def extract_tenure(tenure_obj: dict | None) -> str | None:
|
|
||||||
"""Extract tenure string from tenure object."""
|
|
||||||
if not tenure_obj:
|
|
||||||
return None
|
|
||||||
tt = tenure_obj.get("tenureType", "")
|
|
||||||
if tt == "FREEHOLD":
|
|
||||||
return "Freehold"
|
|
||||||
if tt == "LEASEHOLD":
|
|
||||||
return "Leasehold"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def fix_coords(lat: float, lng: float) -> tuple[float, float]:
|
|
||||||
"""Swap lat/lng if they look reversed. England: lat ~49–56, lng ~-7–2."""
|
|
||||||
if 49 <= lat <= 56 and -7 <= lng <= 2:
|
|
||||||
return lat, lng
|
|
||||||
if 49 <= lng <= 56 and -7 <= lat <= 2:
|
|
||||||
log.debug(
|
|
||||||
"Swapping reversed coords: lat=%.4f lng=%.4f → lat=%.4f lng=%.4f",
|
|
||||||
lat,
|
|
||||||
lng,
|
|
||||||
lng,
|
|
||||||
lat,
|
|
||||||
)
|
|
||||||
return lng, lat
|
|
||||||
log.warning(
|
|
||||||
"Coords outside England bounds even after swap attempt: lat=%.4f lng=%.4f",
|
|
||||||
lat,
|
|
||||||
lng,
|
|
||||||
)
|
|
||||||
return lat, lng
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_postcode(postcode: str) -> str:
|
|
||||||
"""Ensure UK postcode has exactly one space before the 3-char incode.
|
|
||||||
E.g., 'SW1A1AA' → 'SW1A 1AA', 'N4 2HA' → 'N4 2HA', 'E1 4AB' unchanged."""
|
|
||||||
# Strip all whitespace then re-insert the single canonical space
|
|
||||||
compact = re.sub(r"\s+", "", postcode).upper()
|
|
||||||
if len(compact) < 5:
|
|
||||||
return compact
|
|
||||||
return compact[:-3] + " " + compact[-3:]
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_price(amount: int, frequency: str) -> int:
|
|
||||||
"""Normalise price to monthly for rentals (weekly × 52/12, yearly ÷ 12)."""
|
|
||||||
if frequency == "weekly":
|
|
||||||
return round(amount * 52 / 12)
|
|
||||||
if frequency == "yearly":
|
|
||||||
return round(amount / 12)
|
|
||||||
return amount
|
|
||||||
|
|
||||||
|
|
||||||
def transform_property(
|
|
||||||
prop: dict, outcode: str, pc_index: PostcodeSpatialIndex
|
|
||||||
) -> dict | None:
|
|
||||||
"""Transform a raw Rightmove property dict into our output schema."""
|
|
||||||
loc = prop.get("location")
|
|
||||||
if not loc:
|
|
||||||
return None
|
|
||||||
raw_lat = loc.get("latitude")
|
|
||||||
raw_lng = loc.get("longitude")
|
|
||||||
if raw_lat is None or raw_lng is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
lat, lng = fix_coords(raw_lat, raw_lng)
|
|
||||||
|
|
||||||
price_obj = prop.get("price", {})
|
|
||||||
amount = price_obj.get("amount")
|
|
||||||
if not amount:
|
|
||||||
return None
|
|
||||||
frequency = price_obj.get("frequency", "")
|
|
||||||
# Store raw price — normalization to monthly happens once in storage.py
|
|
||||||
price = int(amount)
|
|
||||||
if price <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
display_prices = price_obj.get("displayPrices", [])
|
|
||||||
price_qualifier = (
|
|
||||||
display_prices[0].get("displayPriceQualifier", "") if display_prices else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
# POA / Auction listings have unreliable prices — treat as no price
|
|
||||||
pq_lower = price_qualifier.lower()
|
|
||||||
if "poa" in pq_lower or "auction" in pq_lower:
|
|
||||||
return None
|
|
||||||
|
|
||||||
sub_type = prop.get("propertySubType", "")
|
|
||||||
raw_beds = prop.get("bedrooms", 0) or 0
|
|
||||||
raw_baths = prop.get("bathrooms", 0) or 0
|
|
||||||
bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0
|
|
||||||
bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0
|
|
||||||
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
|
|
||||||
log.warning(
|
|
||||||
"Rightmove %s: implausible beds=%d baths=%d (capped to 0)",
|
|
||||||
prop.get("id", "?"), raw_beds, raw_baths,
|
|
||||||
)
|
|
||||||
|
|
||||||
key_features = [
|
|
||||||
kf.get("description", "")
|
|
||||||
for kf in prop.get("keyFeatures", [])
|
|
||||||
if kf.get("description")
|
|
||||||
]
|
|
||||||
|
|
||||||
postcode = pc_index.nearest(lat, lng)
|
|
||||||
if not postcode:
|
|
||||||
log.debug("No England postcode for property at %.4f, %.4f — skipping", lat, lng)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": prop.get("id"),
|
|
||||||
"Bedrooms": bedrooms,
|
|
||||||
"Bathrooms": bathrooms,
|
|
||||||
"Number of bedrooms & living rooms": bedrooms + bathrooms,
|
|
||||||
"lon": lng,
|
|
||||||
"lat": lat,
|
|
||||||
"Postcode": postcode,
|
|
||||||
"Address per Property Register": prop.get("displayAddress", ""),
|
|
||||||
"Leasehold/Freehold": extract_tenure(prop.get("tenure")),
|
|
||||||
"Property type": map_property_type(sub_type),
|
|
||||||
"Property sub-type": normalize_sub_type(sub_type),
|
|
||||||
"price": price,
|
|
||||||
"price_frequency": frequency,
|
|
||||||
"Price qualifier": price_qualifier,
|
|
||||||
"Total floor area (sqm)": parse_display_size(prop.get("displaySize")),
|
|
||||||
"Listing URL": RIGHTMOVE_BASE + prop.get("propertyUrl", ""),
|
|
||||||
"Listing features": key_features,
|
|
||||||
"first_visible_date": prop.get("firstVisibleDate", ""),
|
|
||||||
}
|
|
||||||
998
finder/uv.lock
generated
998
finder/uv.lock
generated
|
|
@ -1,998 +0,0 @@
|
||||||
version = 1
|
|
||||||
revision = 3
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anyio"
|
|
||||||
version = "4.12.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "idna" },
|
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "apify-fingerprint-datapoints"
|
|
||||||
version = "0.11.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/a9/586b7ebdd682c047cd0b551dc7e154bb1480f8f6548154708e9a6c7844db/apify_fingerprint_datapoints-0.11.0.tar.gz", hash = "sha256:3f905c392b11a27fb59ccfe40891c166abd737ab9c6209733f102bbb3b302515", size = 969830, upload-time = "2026-03-01T01:00:04.737Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/38/9483eb52fc0f00039c684af627f8a8f994a8a99e8eceb869ba93b3fd740b/apify_fingerprint_datapoints-0.11.0-py3-none-any.whl", hash = "sha256:333340ccc3e520f19b5561e95d7abe2b31702e61d34b6247b328c9b8c93fbe1d", size = 726498, upload-time = "2026-03-01T01:00:03.103Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "beautifulsoup4"
|
|
||||||
version = "4.14.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "soupsieve" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "blinker"
|
|
||||||
version = "1.9.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "browserforge"
|
|
||||||
version = "1.2.4"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "apify-fingerprint-datapoints" },
|
|
||||||
{ name = "click" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/78/6f/8975af88d203efd70cc69477ebac702babef38201d04621c9583f2508f25/browserforge-1.2.4.tar.gz", hash = "sha256:05686473793769856ebd3528c69071f5be0e511260993e8b2ba839863711a0c4", size = 36700, upload-time = "2026-02-03T02:52:09.721Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/35/ce962f738ae28ffce6293e7607b129075633e6bb185a5ab87e49246eedc2/browserforge-1.2.4-py3-none-any.whl", hash = "sha256:fb1c14e62ac09de221dcfc73074200269f697596c642cb200ceaab1127a17542", size = 37890, upload-time = "2026-02-03T02:52:08.745Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "camoufox"
|
|
||||||
version = "0.4.11"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "browserforge" },
|
|
||||||
{ name = "click" },
|
|
||||||
{ name = "language-tags" },
|
|
||||||
{ name = "lxml" },
|
|
||||||
{ name = "numpy" },
|
|
||||||
{ name = "orjson" },
|
|
||||||
{ name = "platformdirs" },
|
|
||||||
{ name = "playwright" },
|
|
||||||
{ name = "pysocks" },
|
|
||||||
{ name = "pyyaml" },
|
|
||||||
{ name = "requests" },
|
|
||||||
{ name = "screeninfo" },
|
|
||||||
{ name = "tqdm" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
{ name = "ua-parser" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/15/e0a1b586e354ea6b8d6612717bf4372aaaa6753444d5d006caf0bb116466/camoufox-0.4.11.tar.gz", hash = "sha256:0a2c9d24ac5070c104e7c2b125c0a3937f70efa416084ef88afe94c32a72eebe", size = 64409, upload-time = "2025-01-29T09:33:20.019Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/7b/a2f099a5afb9660271b3f20f6056ba679e7ab4eba42682266a65d5730f7e/camoufox-0.4.11-py3-none-any.whl", hash = "sha256:83864d434d159a7566990aa6524429a8d1a859cbf84d2f64ef4a9f29e7d2e5ff", size = 71628, upload-time = "2025-01-29T09:33:18.558Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "certifi"
|
|
||||||
version = "2026.2.25"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cffi"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "charset-normalizer"
|
|
||||||
version = "3.4.6"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "click"
|
|
||||||
version = "8.3.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorama"
|
|
||||||
version = "0.4.6"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "curl-cffi"
|
|
||||||
version = "0.14.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "cffi" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cython"
|
|
||||||
version = "3.2.4"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/91/85/7574c9cd44b69a27210444b6650f6477f56c75fee1b70d7672d3e4166167/cython-3.2.4.tar.gz", hash = "sha256:84226ecd313b233da27dc2eb3601b4f222b8209c3a7216d8733b031da1dc64e6", size = 3280291, upload-time = "2026-01-04T14:14:14.473Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/4d/1eb0c7c196a136b1926f4d7f0492a96c6fabd604d77e6cd43b56a3a16d83/cython-3.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64d7f71be3dd6d6d4a4c575bb3a4674ea06d1e1e5e4cd1b9882a2bc40ed3c4c9", size = 2970064, upload-time = "2026-01-04T14:15:08.567Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/b5/1cfca43b7d20a0fdb1eac67313d6bb6b18d18897f82dd0f17436bdd2ba7f/cython-3.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:28e8075087a59756f2d059273184b8b639fe0f16cf17470bd91c39921bc154e0", size = 2960506, upload-time = "2026-01-04T14:15:16.733Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/d7/3bda3efce0c5c6ce79cc21285dbe6f60369c20364e112f5a506ee8a1b067/cython-3.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4b4fd5332ab093131fa6172e8362f16adef3eac3179fd24bbdc392531cb82fa", size = 2971496, upload-time = "2026-01-04T14:15:25.038Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/8b/fd393f0923c82be4ec0db712fffb2ff0a7a131707b842c99bf24b549274d/cython-3.2.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36bf3f5eb56d5281aafabecbaa6ed288bc11db87547bba4e1e52943ae6961ccf", size = 2875622, upload-time = "2026-01-04T14:15:39.749Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/fa/d3c15189f7c52aaefbaea76fb012119b04b9013f4bf446cb4eb4c26c4e6b/cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c", size = 1257078, upload-time = "2026-01-04T14:14:12.373Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fake-useragent"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/41/43/948d10bf42735709edb5ae51e23297d034086f17fc7279fef385a7acb473/fake_useragent-2.2.0.tar.gz", hash = "sha256:4e6ab6571e40cc086d788523cf9e018f618d07f9050f822ff409a4dfe17c16b2", size = 158898, upload-time = "2025-04-14T15:32:19.238Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695, upload-time = "2025-04-14T15:32:17.732Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "finder"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = { virtual = "." }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "beautifulsoup4" },
|
|
||||||
{ name = "camoufox" },
|
|
||||||
{ name = "curl-cffi" },
|
|
||||||
{ name = "fake-useragent" },
|
|
||||||
{ name = "flask" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "lxml" },
|
|
||||||
{ name = "playwright" },
|
|
||||||
{ name = "playwright-stealth" },
|
|
||||||
{ name = "polars" },
|
|
||||||
{ name = "prometheus-client" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [
|
|
||||||
{ name = "beautifulsoup4" },
|
|
||||||
{ name = "camoufox", specifier = ">=0.4.11" },
|
|
||||||
{ name = "curl-cffi" },
|
|
||||||
{ name = "fake-useragent", specifier = ">=2.2.0" },
|
|
||||||
{ name = "flask" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "lxml" },
|
|
||||||
{ name = "playwright", specifier = ">=1.58.0" },
|
|
||||||
{ name = "playwright-stealth", specifier = ">=2.0.2" },
|
|
||||||
{ name = "polars" },
|
|
||||||
{ name = "prometheus-client" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "flask"
|
|
||||||
version = "3.1.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "blinker" },
|
|
||||||
{ name = "click" },
|
|
||||||
{ name = "itsdangerous" },
|
|
||||||
{ name = "jinja2" },
|
|
||||||
{ name = "markupsafe" },
|
|
||||||
{ name = "werkzeug" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "greenlet"
|
|
||||||
version = "3.3.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "h11"
|
|
||||||
version = "0.16.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpcore"
|
|
||||||
version = "1.0.9"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "h11" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpx"
|
|
||||||
version = "0.28.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "anyio" },
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "httpcore" },
|
|
||||||
{ name = "idna" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna"
|
|
||||||
version = "3.11"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "itsdangerous"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jinja2"
|
|
||||||
version = "3.1.6"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "markupsafe" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "language-tags"
|
|
||||||
version = "1.2.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/7e/b6a0efe4fee11e9742c1baaedf7c574084238a70b03c1d8eb2761383848f/language_tags-1.2.0.tar.gz", hash = "sha256:e934acba3e3dc85f867703eca421847a9ab7b7679b11b5d5cfd096febbf8bde6", size = 207901, upload-time = "2023-01-11T18:38:07.893Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/42/327554649ed2dd5ce59d3f5da176c7be20f9352c7c6c51597293660b7b08/language_tags-1.2.0-py3-none-any.whl", hash = "sha256:d815604622242fdfbbfd747b40c31213617fd03734a267f2e39ee4bd73c88722", size = 213449, upload-time = "2023-01-11T18:38:05.692Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lxml"
|
|
||||||
version = "6.0.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "markupsafe"
|
|
||||||
version = "3.0.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "numpy"
|
|
||||||
version = "2.4.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "orjson"
|
|
||||||
version = "3.11.7"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "platformdirs"
|
|
||||||
version = "4.9.4"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "playwright"
|
|
||||||
version = "1.58.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "greenlet" },
|
|
||||||
{ name = "pyee" },
|
|
||||||
]
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "playwright-stealth"
|
|
||||||
version = "2.0.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "playwright" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/61/ee/871901103c7b2a12070011fd4d978191f8f962837bf8bb51847274f528fa/playwright_stealth-2.0.2.tar.gz", hash = "sha256:ac57e51873190da5e653e03720e948c8f0a3d06b098f1d56763103d23ee48143", size = 24902, upload-time = "2026-02-13T02:36:25.137Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/30/f95f087f4b071611a7f63a2a0c9af4df3ac046dae2a693bfdacd70512867/playwright_stealth-2.0.2-py3-none-any.whl", hash = "sha256:37a5733f481b9c0ad602cf71491aa5a7c96c2a2fe4fa1e7ab764d2cd35520f2f", size = 33209, upload-time = "2026-02-13T02:36:26.334Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "polars"
|
|
||||||
version = "1.39.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "polars-runtime-32" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/b8/3a6a5b85e34af7936620f331f04f8bed235625439f5bd80832f968648618/polars-1.39.0.tar.gz", hash = "sha256:e63a25fb7682ae660e36067915a7c71a653b17f82308a8eb67a190a80daf0710", size = 728783, upload-time = "2026-03-12T14:24:47.876Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/f8/fad8470d9701c1b208cc24919a661efdf565373e77e7d06400642a759285/polars-1.39.0-py3-none-any.whl", hash = "sha256:4d1198b41bc47561673d9f54d0f595125202a3f53e3502821802958d3e60efe9", size = 823938, upload-time = "2026-03-12T14:22:37.78Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "polars-runtime-32"
|
|
||||||
version = "1.39.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/1e/fce83ad77bfed1bf4a83f74dde19e2572c32fc040e93bd98d161e3950eaf/polars_runtime_32-1.39.0.tar.gz", hash = "sha256:f5aabed8c7318fcad5173e83bee385445f54b5f8c83b1ec9eab78bdffa293141", size = 2870686, upload-time = "2026-03-12T14:24:49.41Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/6d/143b552baa9e859ae266f087f3ec0aeb29e5acc39e1f49c1a64023cee469/polars_runtime_32-1.39.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4a4bc06ca97238d963979e3f888fbb500ee607f03cefe43a9062381e259503e2", size = 45299222, upload-time = "2026-03-12T14:22:40.821Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/ec/eb4e57eedfb97019f951b298fa4cd232a50db65aa6702c735b6f272a0fa0/polars_runtime_32-1.39.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9914b9e168634bc21d07ee03b8fa92d0aaa8ac7b2bb1c9e2f1f78622aa1b8f4", size = 40863978, upload-time = "2026-03-12T14:22:45.16Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/b7/28fa0345586f7c449dd27d687c32a10dcea470ebc5a978d7fc47e463b298/polars_runtime_32-1.39.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ded58f1c28e17ecbff8625cb1ad93016761260348acb79b1a4cd077970e89e5", size = 43231627, upload-time = "2026-03-12T14:22:49.464Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/60/c0d0b6720437685223457242a79f6bba443485ca85928645786479ebed86/polars_runtime_32-1.39.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b82c872b25ef6628462f90f1b6b3950779aee36889e83b3693d0a69684d3d86a", size = 46899324, upload-time = "2026-03-12T14:22:54.364Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/98/53ad9c8a6f151e098e4f65c5146f9e538f1ba148feb5289fd2a4c5e2d764/polars_runtime_32-1.39.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4a0e9d6b56362f3ba1a33d0538ae14c9b9a8e0fb835f86abfc82fa7b2c7d89c9", size = 43389283, upload-time = "2026-03-12T14:22:59.767Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/a2/21f77d6e588ee7c8e7f6232d135538690411de2ea6415d8bbe9b8d684f37/polars_runtime_32-1.39.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0daea3919661ba672b00bd01b5547cd29bb6414732457abb72cbc75103cf3c90", size = 46509946, upload-time = "2026-03-12T14:23:05.215Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/a3/37a56ad2d931c857b892b22760b9bf9a53f681d9ccf27741cf6dd8489320/polars_runtime_32-1.39.0-cp310-abi3-win_amd64.whl", hash = "sha256:d6e9d1cf264aacfe5bf03241c04ef435d0f9cfec3fbe079acc3a7328a737961a", size = 47012669, upload-time = "2026-03-12T14:23:11.134Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/eb/936f5eeae196e8c8aaabe5f7d98891be8a5bbc741d50ce5c60f55575ad29/polars_runtime_32-1.39.0-cp310-abi3-win_arm64.whl", hash = "sha256:d69abde5f148566860bbe910010847bd7791e72f7c8063a4d2c462246a33a72a", size = 41885761, upload-time = "2026-03-12T14:23:16.773Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prometheus-client"
|
|
||||||
version = "0.24.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pycparser"
|
|
||||||
version = "3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyee"
|
|
||||||
version = "13.0.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyobjc-core"
|
|
||||||
version = "12.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyobjc-framework-cocoa"
|
|
||||||
version = "12.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "pyobjc-core" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pysocks"
|
|
||||||
version = "1.7.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyyaml"
|
|
||||||
version = "6.0.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "requests"
|
|
||||||
version = "2.32.5"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "charset-normalizer" },
|
|
||||||
{ name = "idna" },
|
|
||||||
{ name = "urllib3" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "screeninfo"
|
|
||||||
version = "0.8.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "cython", marker = "sys_platform == 'darwin'" },
|
|
||||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/bb/e69e5e628d43f118e0af4fc063c20058faa8635c95a1296764acc8167e27/screeninfo-0.8.1.tar.gz", hash = "sha256:9983076bcc7e34402a1a9e4d7dabf3729411fd2abb3f3b4be7eba73519cd2ed1", size = 10666, upload-time = "2022-09-09T11:35:23.419Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/bf/c5205d480307bef660e56544b9e3d7ff687da776abb30c9cb3f330887570/screeninfo-0.8.1-py3-none-any.whl", hash = "sha256:e97d6b173856edcfa3bd282f81deb528188aff14b11ec3e195584e7641be733c", size = 12907, upload-time = "2022-09-09T11:35:21.351Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "soupsieve"
|
|
||||||
version = "2.8.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tqdm"
|
|
||||||
version = "4.67.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-extensions"
|
|
||||||
version = "4.15.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ua-parser"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "ua-parser-builtins" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/70/0e/ed98be735bc89d5040e0c60f5620d0b8c04e9e7da99ed1459e8050e90a77/ua_parser-1.0.1.tar.gz", hash = "sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d", size = 728106, upload-time = "2025-02-01T14:13:32.508Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/37/be6dfbfa45719aa82c008fb4772cfe5c46db765a2ca4b6f524a1fdfee4d7/ua_parser-1.0.1-py3-none-any.whl", hash = "sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea", size = 31410, upload-time = "2025-02-01T14:13:28.458Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ua-parser-builtins"
|
|
||||||
version = "202603"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/6f/73a4d37deefb159556d39d654b5bad67b6874d1ad0b20b96fb5a04de3949/ua_parser_builtins-202603-py3-none-any.whl", hash = "sha256:67478397a68fac1a98fd0a31c416ea7c65a719141fc151d0211316f2cd337cc9", size = 89573, upload-time = "2026-03-01T20:50:02.491Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "urllib3"
|
|
||||||
version = "2.6.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "werkzeug"
|
|
||||||
version = "3.1.6"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "markupsafe" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
|
|
||||||
]
|
|
||||||
893
finder/zoopla.py
893
finder/zoopla.py
|
|
@ -1,893 +0,0 @@
|
||||||
"""Zoopla (zoopla.co.uk) scraper — buy and rental properties.
|
|
||||||
|
|
||||||
Zoopla is behind Cloudflare Turnstile (managed interactive challenge), which
|
|
||||||
blocks all HTTP clients (curl_cffi, httpx) and even Playwright with stealth
|
|
||||||
patches. Only Camoufox (an anti-fingerprinting Firefox fork) passes reliably.
|
|
||||||
|
|
||||||
Zoopla uses Next.js App Router with React Server Components (RSC). Search
|
|
||||||
result data is server-rendered in an RSC stream, not available via
|
|
||||||
__NEXT_DATA__ or a JSON API. URL-based location slugs return 0 results —
|
|
||||||
the working flow requires typing into the autocomplete input, selecting a
|
|
||||||
suggestion, and clicking Search.
|
|
||||||
|
|
||||||
Architecture:
|
|
||||||
Unlike the other scrapers which use HTTP clients per outcode, Zoopla keeps
|
|
||||||
a single Camoufox browser alive for the entire scrape. For each outcode, it:
|
|
||||||
1. Clears and types the outcode into the search input
|
|
||||||
2. Selects the first autocomplete suggestion
|
|
||||||
3. Clicks Search
|
|
||||||
4. Extracts listing data from the rendered DOM
|
|
||||||
5. Handles pagination via ?pn=N parameter
|
|
||||||
|
|
||||||
The browser session replaces the cookie/client pattern used by other scrapers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
|
|
||||||
from constants import DELAY_BETWEEN_PAGES, MAX_BEDROOMS, PROPERTY_TYPE_MAP, ZOOPLA_BASE
|
|
||||||
from metrics import zoopla_errors_total, zoopla_pages_scraped, zoopla_properties_scraped
|
|
||||||
from spatial import PostcodeSpatialIndex
|
|
||||||
from transform import normalize_sub_type, validate_floor_area
|
|
||||||
|
|
||||||
log = logging.getLogger("zoopla")
|
|
||||||
|
|
||||||
|
|
||||||
class TurnstileError(Exception):
|
|
||||||
"""Raised when Cloudflare Turnstile challenge cannot be passed."""
|
|
||||||
|
|
||||||
|
|
||||||
# Maximum search result pages to scrape per outcode (25 listings/page)
|
|
||||||
MAX_PAGES_PER_OUTCODE = 40
|
|
||||||
|
|
||||||
# JavaScript to extract listings from the rendered DOM.
|
|
||||||
# Uses data-testid attributes as primary selectors (stable across deployments),
|
|
||||||
# then falls back to href-based link matching with parent-walking.
|
|
||||||
_EXTRACT_LISTINGS_JS = r"""() => {
|
|
||||||
const seen = new Set();
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
// Strategy 1: Use data-testid selectors (post-2025 redesign)
|
|
||||||
const listingCards = document.querySelectorAll(
|
|
||||||
'[data-testid="regular-listings"] > div, [data-testid="search-content"] li'
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const card of listingCards) {
|
|
||||||
const link = card.querySelector(
|
|
||||||
'a[href*="/for-sale/details/"], a[href*="/new-homes/details/"], a[href*="/to-rent/details/"]'
|
|
||||||
);
|
|
||||||
if (!link) continue;
|
|
||||||
|
|
||||||
const href = link.href;
|
|
||||||
const match = href.match(/\/details\/(\d+)\//);
|
|
||||||
if (!match) continue;
|
|
||||||
|
|
||||||
const id = match[1];
|
|
||||||
if (seen.has(id)) continue;
|
|
||||||
seen.add(id);
|
|
||||||
|
|
||||||
const text = card.innerText || '';
|
|
||||||
|
|
||||||
// Try data-testid price element first, then regex
|
|
||||||
const priceEl = card.querySelector('[data-testid="listing-price"]');
|
|
||||||
const priceText = priceEl ? priceEl.innerText : text;
|
|
||||||
const priceMatch = priceText.match(/\u00a3([\d,]+)/);
|
|
||||||
|
|
||||||
// Try address element first, then regex
|
|
||||||
const addressEl = card.querySelector('address');
|
|
||||||
let address = addressEl ? addressEl.innerText.trim() : '';
|
|
||||||
|
|
||||||
if (!address) {
|
|
||||||
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
|
||||||
for (const line of lines) {
|
|
||||||
if (/[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}/i.test(line) ||
|
|
||||||
(line.includes(',') && !line.includes('\u00a3') && !/^\d+ beds?/i.test(line))) {
|
|
||||||
address = line;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bedsMatch = text.match(/(\d+)\s*beds?/i);
|
|
||||||
const bathsMatch = text.match(/(\d+)\s*baths?/i);
|
|
||||||
const recMatch = text.match(/(\d+)\s*reception/i);
|
|
||||||
const areaMatch = text.match(/([\d,]+)\s*sq\.?\s*ft/i);
|
|
||||||
|
|
||||||
let tenure = '';
|
|
||||||
if (/leasehold/i.test(text)) tenure = 'Leasehold';
|
|
||||||
else if (/freehold/i.test(text)) tenure = 'Freehold';
|
|
||||||
|
|
||||||
// Extract property type (e.g., "2 bed flat for sale" → "flat")
|
|
||||||
let property_type = '';
|
|
||||||
const ptMatch = text.match(/\d+\s*(?:beds?|bedrooms?)\s+([\w\s-]+?)\s+(?:for\s+sale|to\s+(?:rent|let)|for\s+rent)/i);
|
|
||||||
if (ptMatch) property_type = ptMatch[1].trim();
|
|
||||||
else if (/\bstudio\s*(?:flat|apartment)?\s+(?:for\s+sale|to\s+(?:rent|let)|for\s+rent)/i.test(text)) property_type = 'Studio';
|
|
||||||
|
|
||||||
// Keyword fallback when regex doesn't match current DOM format
|
|
||||||
if (!property_type) {
|
|
||||||
const lower = text.toLowerCase();
|
|
||||||
if (/\bstudio\b/.test(lower)) property_type = 'Studio';
|
|
||||||
else if (/\bpenthouse\b/.test(lower)) property_type = 'Penthouse';
|
|
||||||
else if (/\bmaisonette\b/.test(lower)) property_type = 'Maisonette';
|
|
||||||
else if (/\bapartment\b/.test(lower)) property_type = 'Apartment';
|
|
||||||
else if (/\bflat\b/.test(lower)) property_type = 'Flat';
|
|
||||||
else if (/\bsemi[- ]?detached\b/.test(lower)) property_type = 'Semi-Detached';
|
|
||||||
else if (/\bdetached\b/.test(lower)) property_type = 'Detached';
|
|
||||||
else if (/\bterraced?\b/.test(lower)) property_type = 'Terraced';
|
|
||||||
else if (/\bbungalow\b/.test(lower)) property_type = 'Bungalow';
|
|
||||||
else if (/\bcottage\b/.test(lower)) property_type = 'Cottage';
|
|
||||||
else if (/\bhouse\b/.test(lower)) property_type = 'House';
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
id, url: href.replace(window.location.origin, ''),
|
|
||||||
price: priceMatch ? parseInt(priceMatch[1].replace(/,/g, '')) : null,
|
|
||||||
price_text: priceText.trim(),
|
|
||||||
beds: bedsMatch && parseInt(bedsMatch[1]) <= 20 ? parseInt(bedsMatch[1]) : null,
|
|
||||||
baths: bathsMatch && parseInt(bathsMatch[1]) <= 20 ? parseInt(bathsMatch[1]) : null,
|
|
||||||
receptions: recMatch && parseInt(recMatch[1]) <= 20 ? parseInt(recMatch[1]) : null,
|
|
||||||
floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
|
|
||||||
address, tenure, property_type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: Fall back to href-based link matching with parent-walking
|
|
||||||
if (results.length === 0) {
|
|
||||||
const links = Array.from(document.querySelectorAll(
|
|
||||||
'a[href*="/for-sale/details/"], a[href*="/new-homes/details/"], a[href*="/to-rent/details/"]'
|
|
||||||
));
|
|
||||||
|
|
||||||
for (const link of links) {
|
|
||||||
const href = link.href;
|
|
||||||
const match = href.match(/\/details\/(\d+)\//);
|
|
||||||
if (!match) continue;
|
|
||||||
|
|
||||||
const id = match[1];
|
|
||||||
if (seen.has(id)) continue;
|
|
||||||
seen.add(id);
|
|
||||||
|
|
||||||
let card = link;
|
|
||||||
for (let j = 0; j < 15; j++) {
|
|
||||||
card = card.parentElement;
|
|
||||||
if (!card) break;
|
|
||||||
const t = card.innerText || '';
|
|
||||||
if (t.includes('\u00a3') && (t.includes('bed') || t.includes('Bath') || t.includes('sq ft'))) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!card) continue;
|
|
||||||
|
|
||||||
const text = card.innerText || '';
|
|
||||||
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
|
||||||
|
|
||||||
const priceEl2 = card.querySelector('[data-testid="listing-price"]');
|
|
||||||
const priceText2 = priceEl2 ? priceEl2.innerText : text;
|
|
||||||
const priceMatch = priceText2.match(/\u00a3([\d,]+)/);
|
|
||||||
const bedsMatch = text.match(/(\d+)\s*beds?/i);
|
|
||||||
const bathsMatch = text.match(/(\d+)\s*baths?/i);
|
|
||||||
const recMatch = text.match(/(\d+)\s*reception/i);
|
|
||||||
const areaMatch = text.match(/([\d,]+)\s*sq\.?\s*ft/i);
|
|
||||||
|
|
||||||
let address = '';
|
|
||||||
for (const line of lines) {
|
|
||||||
if (/[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}/i.test(line) ||
|
|
||||||
(line.includes(',') && !line.includes('\u00a3') && !/^\d+ beds?/i.test(line))) {
|
|
||||||
address = line;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tenure = '';
|
|
||||||
if (/leasehold/i.test(text)) tenure = 'Leasehold';
|
|
||||||
else if (/freehold/i.test(text)) tenure = 'Freehold';
|
|
||||||
|
|
||||||
// Extract property type
|
|
||||||
let property_type = '';
|
|
||||||
const ptMatch2 = text.match(/\d+\s*(?:beds?|bedrooms?)\s+([\w\s-]+?)\s+(?:for\s+sale|to\s+(?:rent|let)|for\s+rent)/i);
|
|
||||||
if (ptMatch2) property_type = ptMatch2[1].trim();
|
|
||||||
else if (/\bstudio\s*(?:flat|apartment)?\s+(?:for\s+sale|to\s+(?:rent|let)|for\s+rent)/i.test(text)) property_type = 'Studio';
|
|
||||||
|
|
||||||
// Keyword fallback when regex doesn't match current DOM format
|
|
||||||
if (!property_type) {
|
|
||||||
const lower = text.toLowerCase();
|
|
||||||
if (/\bstudio\b/.test(lower)) property_type = 'Studio';
|
|
||||||
else if (/\bpenthouse\b/.test(lower)) property_type = 'Penthouse';
|
|
||||||
else if (/\bmaisonette\b/.test(lower)) property_type = 'Maisonette';
|
|
||||||
else if (/\bapartment\b/.test(lower)) property_type = 'Apartment';
|
|
||||||
else if (/\bflat\b/.test(lower)) property_type = 'Flat';
|
|
||||||
else if (/\bsemi[- ]?detached\b/.test(lower)) property_type = 'Semi-Detached';
|
|
||||||
else if (/\bdetached\b/.test(lower)) property_type = 'Detached';
|
|
||||||
else if (/\bterraced?\b/.test(lower)) property_type = 'Terraced';
|
|
||||||
else if (/\bbungalow\b/.test(lower)) property_type = 'Bungalow';
|
|
||||||
else if (/\bcottage\b/.test(lower)) property_type = 'Cottage';
|
|
||||||
else if (/\bhouse\b/.test(lower)) property_type = 'House';
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
id, url: href.replace(window.location.origin, ''),
|
|
||||||
price: priceMatch ? parseInt(priceMatch[1].replace(/,/g, '')) : null,
|
|
||||||
price_text: priceText2.trim(),
|
|
||||||
beds: bedsMatch && parseInt(bedsMatch[1]) <= 20 ? parseInt(bedsMatch[1]) : null,
|
|
||||||
baths: bathsMatch && parseInt(bathsMatch[1]) <= 20 ? parseInt(bathsMatch[1]) : null,
|
|
||||||
receptions: recMatch && parseInt(recMatch[1]) <= 20 ? parseInt(recMatch[1]) : null,
|
|
||||||
floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
|
|
||||||
address, tenure, property_type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}"""
|
|
||||||
|
|
||||||
# JavaScript to dismiss the Usercentrics cookie consent overlay (shadow DOM).
|
|
||||||
_DISMISS_COOKIES_JS = """() => {
|
|
||||||
const aside = document.querySelector('#usercentrics-cmp-ui');
|
|
||||||
if (aside && aside.shadowRoot) {
|
|
||||||
const btns = aside.shadowRoot.querySelectorAll('button');
|
|
||||||
for (const btn of btns) {
|
|
||||||
if (btn.innerText.includes('Accept')) { btn.click(); return true; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (aside) { aside.remove(); return true; }
|
|
||||||
return false;
|
|
||||||
}"""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Browser lifecycle
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def launch_browser():
|
|
||||||
"""Launch Camoufox, navigate to Zoopla homepage, pass Cloudflare Turnstile,
|
|
||||||
and dismiss cookie consent. Returns (browser, page) tuple.
|
|
||||||
|
|
||||||
Raises TurnstileError if Cloudflare cannot be passed within 60 seconds.
|
|
||||||
Caller must close browser when done."""
|
|
||||||
from camoufox.pkgman import camoufox_path
|
|
||||||
|
|
||||||
# Verify camoufox is pre-installed — never download at runtime
|
|
||||||
camoufox_path(download_if_missing=False)
|
|
||||||
|
|
||||||
from camoufox.sync_api import Camoufox
|
|
||||||
|
|
||||||
log.info("Launching Camoufox browser for Zoopla...")
|
|
||||||
browser = Camoufox(headless=True).__enter__()
|
|
||||||
page = browser.new_page()
|
|
||||||
|
|
||||||
log.info("Navigating to Zoopla homepage...")
|
|
||||||
page.goto(f"{ZOOPLA_BASE}/", wait_until="domcontentloaded", timeout=60000)
|
|
||||||
|
|
||||||
# Wait for Cloudflare Turnstile to resolve.
|
|
||||||
# Try clicking the Turnstile checkbox if present (helps in some cases).
|
|
||||||
for i in range(20):
|
|
||||||
if "Just a moment" not in page.title():
|
|
||||||
break
|
|
||||||
# Attempt to click the Turnstile checkbox in the challenge iframe
|
|
||||||
for frame in page.frames:
|
|
||||||
if "challenges.cloudflare.com" in frame.url:
|
|
||||||
try:
|
|
||||||
iframe_el = page.query_selector('iframe[src*="challenges.cloudflare"]')
|
|
||||||
if iframe_el:
|
|
||||||
box = iframe_el.bounding_box()
|
|
||||||
if box:
|
|
||||||
page.mouse.click(box["x"] + 30, box["y"] + box["height"] / 2)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
break
|
|
||||||
time.sleep(3)
|
|
||||||
else:
|
|
||||||
page.close()
|
|
||||||
browser.close()
|
|
||||||
raise TurnstileError("Cloudflare Turnstile did not resolve after 60s")
|
|
||||||
|
|
||||||
log.info("Cloudflare passed — title: %s", page.title())
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
# Dismiss cookie consent
|
|
||||||
page.evaluate(_DISMISS_COOKIES_JS)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
return browser, page
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_not_challenged(page) -> None:
|
|
||||||
"""Check if current page is a Cloudflare challenge and wait/raise."""
|
|
||||||
if "Just a moment" not in page.title():
|
|
||||||
return
|
|
||||||
|
|
||||||
log.warning("Cloudflare challenge detected mid-session, waiting...")
|
|
||||||
for i in range(20):
|
|
||||||
time.sleep(3)
|
|
||||||
if "Just a moment" not in page.title():
|
|
||||||
log.info("Cloudflare challenge resolved")
|
|
||||||
return
|
|
||||||
|
|
||||||
raise TurnstileError("Cloudflare re-challenge did not resolve")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Search navigation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _navigate_direct(page, url: str) -> bool:
|
|
||||||
"""Navigate directly to a Zoopla search URL (skipping the homepage flow).
|
|
||||||
|
|
||||||
Used to load the second channel (e.g., RENT after BUY) for the same outcode
|
|
||||||
by swapping the path component. Falls back gracefully — returns False if
|
|
||||||
the page has no listings, so the caller can retry via the full search flow.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
|
||||||
except Exception as e:
|
|
||||||
log.debug("Direct navigation failed: %s", e)
|
|
||||||
return False
|
|
||||||
_ensure_not_challenged(page)
|
|
||||||
|
|
||||||
# Wait for listing content to hydrate
|
|
||||||
try:
|
|
||||||
page.wait_for_function(
|
|
||||||
"""() => {
|
|
||||||
const cards = document.querySelectorAll(
|
|
||||||
'[data-testid="regular-listings"] > div'
|
|
||||||
);
|
|
||||||
if (cards.length === 0) return false;
|
|
||||||
for (const card of cards) {
|
|
||||||
const t = card.innerText || '';
|
|
||||||
if (t.includes('\\u00a3') && t.length > 50) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}""",
|
|
||||||
timeout=8000,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Check if the page has any listings at all
|
|
||||||
has_listings = page.query_selector('a[href*="/details/"]')
|
|
||||||
if not has_listings:
|
|
||||||
return False
|
|
||||||
time.sleep(1.5)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _navigate_search(page, outcode: str, channel: str) -> bool:
|
|
||||||
"""Navigate to search results for an outcode via the homepage search flow.
|
|
||||||
|
|
||||||
Returns True if results were found, False if no results or navigation failed.
|
|
||||||
Raises TurnstileError if Cloudflare blocks us."""
|
|
||||||
# Navigate to homepage to reset search state
|
|
||||||
page.goto(f"{ZOOPLA_BASE}/", wait_until="domcontentloaded", timeout=30000)
|
|
||||||
time.sleep(0.5)
|
|
||||||
_ensure_not_challenged(page)
|
|
||||||
|
|
||||||
# Dismiss cookie consent (may reappear after navigation)
|
|
||||||
page.evaluate(_DISMISS_COOKIES_JS)
|
|
||||||
time.sleep(0.3)
|
|
||||||
|
|
||||||
# Select Buy/Rent tab
|
|
||||||
if channel == "RENT":
|
|
||||||
rent_tab = page.query_selector(
|
|
||||||
'button:has-text("Rent"), [role="tab"]:has-text("Rent")'
|
|
||||||
)
|
|
||||||
if rent_tab:
|
|
||||||
rent_tab.click()
|
|
||||||
time.sleep(0.2)
|
|
||||||
|
|
||||||
# Find and fill search input
|
|
||||||
search_input = page.query_selector(
|
|
||||||
'input[name="autosuggest-input"]'
|
|
||||||
) or page.query_selector('input[type="text"]')
|
|
||||||
if not search_input:
|
|
||||||
log.warning("Could not find search input on homepage")
|
|
||||||
return False
|
|
||||||
|
|
||||||
search_input.click()
|
|
||||||
time.sleep(0.1)
|
|
||||||
search_input.fill("")
|
|
||||||
search_input.type(outcode, delay=60)
|
|
||||||
time.sleep(1.2)
|
|
||||||
|
|
||||||
# Select first autocomplete suggestion
|
|
||||||
first_option = page.query_selector('[role="option"]')
|
|
||||||
if not first_option:
|
|
||||||
log.debug("No autocomplete suggestions for outcode %s", outcode)
|
|
||||||
return False
|
|
||||||
|
|
||||||
first_option.click()
|
|
||||||
time.sleep(0.2)
|
|
||||||
|
|
||||||
# Click search button
|
|
||||||
search_btn = page.query_selector('button:has-text("Search")')
|
|
||||||
if search_btn:
|
|
||||||
search_btn.click()
|
|
||||||
else:
|
|
||||||
search_input.press("Enter")
|
|
||||||
|
|
||||||
# Wait for results to load — try waiting for listings container, fall back to fixed wait
|
|
||||||
try:
|
|
||||||
page.wait_for_selector(
|
|
||||||
'[data-testid="regular-listings"], a[href*="/details/"]',
|
|
||||||
timeout=10000,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
time.sleep(4)
|
|
||||||
_ensure_not_challenged(page)
|
|
||||||
|
|
||||||
# Wait for client-side hydration to populate listing content (prices, addresses).
|
|
||||||
# The structural container appears in server-rendered HTML before React hydrates
|
|
||||||
# the actual card content — extracting too early yields empty price/address fields.
|
|
||||||
try:
|
|
||||||
page.wait_for_function(
|
|
||||||
"""() => {
|
|
||||||
const cards = document.querySelectorAll(
|
|
||||||
'[data-testid="regular-listings"] > div'
|
|
||||||
);
|
|
||||||
if (cards.length === 0) return false;
|
|
||||||
for (const card of cards) {
|
|
||||||
const t = card.innerText || '';
|
|
||||||
if (t.includes('\\u00a3') && t.length > 50) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}""",
|
|
||||||
timeout=8000,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Content never appeared — extraction will likely fail but let it try
|
|
||||||
log.debug("Listing content hydration wait timed out — prices may not have rendered")
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _get_result_count(page) -> int:
|
|
||||||
"""Extract the total results count from the page.
|
|
||||||
|
|
||||||
Tries __ZAD_TARGETING__ JSON first (most reliable), then body text regex
|
|
||||||
matching both "N results" and "N properties" patterns."""
|
|
||||||
try:
|
|
||||||
# Try the ZAD targeting JSON script tag first
|
|
||||||
count = page.evaluate("""() => {
|
|
||||||
const s = document.querySelector('#__ZAD_TARGETING__');
|
|
||||||
if (s) {
|
|
||||||
try {
|
|
||||||
const d = JSON.parse(s.textContent);
|
|
||||||
if (d.search_results_count != null) return d.search_results_count;
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}""")
|
|
||||||
if count is not None and count > 0:
|
|
||||||
return count
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
body = page.inner_text("body")
|
|
||||||
match = re.search(r"([\d,]+)\s+(?:results?|properties)", body)
|
|
||||||
if match:
|
|
||||||
return int(match.group(1).replace(",", ""))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Extraction and pagination
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
_first_extraction_logged = False
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_listings(page) -> list[dict]:
|
|
||||||
"""Extract listing data from the current search results page DOM."""
|
|
||||||
global _first_extraction_logged
|
|
||||||
try:
|
|
||||||
listings = page.evaluate(_EXTRACT_LISTINGS_JS)
|
|
||||||
|
|
||||||
# Log diagnostic info on the very first extraction attempt
|
|
||||||
if not _first_extraction_logged:
|
|
||||||
_first_extraction_logged = True
|
|
||||||
try:
|
|
||||||
diag = page.evaluate("""() => {
|
|
||||||
const details = document.querySelectorAll('a[href*="/details/"]');
|
|
||||||
const testids = document.querySelectorAll('[data-testid]');
|
|
||||||
const testidNames = [...new Set([...testids].map(e => e.dataset.testid))];
|
|
||||||
return {
|
|
||||||
url: location.href,
|
|
||||||
title: document.title,
|
|
||||||
detailLinks: details.length,
|
|
||||||
testids: testidNames.slice(0, 30),
|
|
||||||
bodySnippet: document.body?.innerText?.slice(0, 500) || '',
|
|
||||||
};
|
|
||||||
}""")
|
|
||||||
log.info(
|
|
||||||
"Zoopla first-page diagnostic: url=%s title=%s detailLinks=%d "
|
|
||||||
"testids=%s bodySnippet=%.200s",
|
|
||||||
diag.get("url"), diag.get("title"), diag.get("detailLinks", 0),
|
|
||||||
diag.get("testids", []), diag.get("bodySnippet", ""),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
log.info("Zoopla first extraction: %d listings found", len(listings))
|
|
||||||
|
|
||||||
return listings
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("Failed to extract listings from DOM: %s", e)
|
|
||||||
zoopla_errors_total.labels(type="extract_failed").inc()
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _paginate(page, total_results: int, channel: str) -> list[dict]:
|
|
||||||
"""Extract listings from all pages of search results.
|
|
||||||
|
|
||||||
Page 1 is already loaded. For subsequent pages, clicks the Next button
|
|
||||||
or navigates via URL parameter ?pn=N."""
|
|
||||||
all_listings = _extract_listings(page)
|
|
||||||
channel_label = "buy" if channel == "BUY" else "rent"
|
|
||||||
zoopla_pages_scraped.labels(channel=channel_label).inc()
|
|
||||||
|
|
||||||
if not all_listings or total_results <= len(all_listings):
|
|
||||||
return all_listings
|
|
||||||
|
|
||||||
seen_ids = {listing["id"] for listing in all_listings}
|
|
||||||
current_url = page.url
|
|
||||||
page_num = 2
|
|
||||||
|
|
||||||
while len(all_listings) < total_results and page_num <= MAX_PAGES_PER_OUTCODE:
|
|
||||||
time.sleep(DELAY_BETWEEN_PAGES)
|
|
||||||
|
|
||||||
# Try navigating via URL parameter
|
|
||||||
if "?" in current_url:
|
|
||||||
next_url = re.sub(r"[?&]pn=\d+", "", current_url)
|
|
||||||
separator = "&" if "?" in next_url else "?"
|
|
||||||
next_url = f"{next_url}{separator}pn={page_num}"
|
|
||||||
else:
|
|
||||||
next_url = f"{current_url}?pn={page_num}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
page.goto(next_url, wait_until="domcontentloaded", timeout=30000)
|
|
||||||
_ensure_not_challenged(page)
|
|
||||||
# Wait for listing content instead of fixed sleep
|
|
||||||
try:
|
|
||||||
page.wait_for_function(
|
|
||||||
"""() => {
|
|
||||||
const cards = document.querySelectorAll(
|
|
||||||
'[data-testid="regular-listings"] > div'
|
|
||||||
);
|
|
||||||
if (cards.length === 0) return false;
|
|
||||||
for (const card of cards) {
|
|
||||||
const t = card.innerText || '';
|
|
||||||
if (t.includes('\\u00a3') && t.length > 50) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}""",
|
|
||||||
timeout=8000,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
time.sleep(1.5)
|
|
||||||
except TurnstileError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
log.debug("Pagination navigation failed at page %d: %s", page_num, e)
|
|
||||||
break
|
|
||||||
|
|
||||||
page_listings = _extract_listings(page)
|
|
||||||
if not page_listings:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Deduplicate within this outcode
|
|
||||||
new_count = 0
|
|
||||||
for listing in page_listings:
|
|
||||||
if listing["id"] not in seen_ids:
|
|
||||||
seen_ids.add(listing["id"])
|
|
||||||
all_listings.append(listing)
|
|
||||||
new_count += 1
|
|
||||||
|
|
||||||
zoopla_pages_scraped.labels(channel=channel_label).inc()
|
|
||||||
|
|
||||||
if new_count == 0:
|
|
||||||
break # No new listings on this page
|
|
||||||
|
|
||||||
page_num += 1
|
|
||||||
|
|
||||||
return all_listings
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Property transformation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# Cached outcode → (postcode, lat, lng) lookups to avoid repeated O(n) scans
|
|
||||||
# over 2.26M postcodes. Populated lazily on first lookup per outcode.
|
|
||||||
_outcode_coords_cache: dict[str, tuple[str, float, float] | None] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_outcode_coords(
|
|
||||||
outcode: str, pc_coords: dict[str, tuple[float, float]]
|
|
||||||
) -> tuple[str, float, float] | None:
|
|
||||||
"""Find first postcode + coords for an outcode. Result is cached."""
|
|
||||||
if outcode in _outcode_coords_cache:
|
|
||||||
return _outcode_coords_cache[outcode]
|
|
||||||
|
|
||||||
prefix = outcode + " "
|
|
||||||
for pcd, (lat, lng) in pc_coords.items():
|
|
||||||
if pcd.startswith(prefix) or (
|
|
||||||
len(outcode) >= 4
|
|
||||||
and pcd.startswith(outcode)
|
|
||||||
and len(pcd) > len(outcode)
|
|
||||||
):
|
|
||||||
_outcode_coords_cache[outcode] = (pcd, lat, lng)
|
|
||||||
return (pcd, lat, lng)
|
|
||||||
|
|
||||||
_outcode_coords_cache[outcode] = None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_postcode(text: str) -> str | None:
|
|
||||||
"""Extract a full UK postcode from text like 'Dollar Bay Place, Canary Wharf E14 9SS'.
|
|
||||||
Normalizes to include a space before the 3-char incode."""
|
|
||||||
match = re.search(r"([A-Z]{1,2}\d[A-Z0-9]?\s*\d[A-Z]{2})", text, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
raw = match.group(1).upper().strip()
|
|
||||||
# Ensure space before incode (last 3 chars): "SW1A1AA" → "SW1A 1AA"
|
|
||||||
if " " not in raw and len(raw) >= 5:
|
|
||||||
return raw[:-3] + " " + raw[-3:]
|
|
||||||
return raw
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_outcode(text: str) -> str | None:
|
|
||||||
"""Extract a UK outcode from address text like 'Whitechapel Road, London E1'."""
|
|
||||||
# Look for outcode at end of string or after last comma
|
|
||||||
match = re.search(r"\b([A-Z]{1,2}\d[A-Z0-9]?)\s*$", text.strip(), re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
return match.group(1).upper()
|
|
||||||
# Try after comma
|
|
||||||
parts = text.split(",")
|
|
||||||
if len(parts) > 1:
|
|
||||||
last = parts[-1].strip()
|
|
||||||
match = re.match(r"^([A-Z]{1,2}\d[A-Z0-9]?)$", last, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
return match.group(1).upper()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _map_property_type(raw_type: str | None) -> str:
|
|
||||||
"""Map Zoopla property type text to canonical type."""
|
|
||||||
if not raw_type:
|
|
||||||
return "Other"
|
|
||||||
# Exact match (handles Rightmove-style capitalised values)
|
|
||||||
canonical = PROPERTY_TYPE_MAP.get(raw_type)
|
|
||||||
if canonical:
|
|
||||||
return canonical
|
|
||||||
# Title-case match (handles regex-extracted lowercase like "town house" → "Town House")
|
|
||||||
canonical = PROPERTY_TYPE_MAP.get(raw_type.title())
|
|
||||||
if canonical:
|
|
||||||
return canonical
|
|
||||||
# Lowercase match (e.g., "Townhouse" → "townhouse")
|
|
||||||
canonical = PROPERTY_TYPE_MAP.get(raw_type.lower())
|
|
||||||
if canonical:
|
|
||||||
return canonical
|
|
||||||
# Normalize delimiters (underscores/hyphens → spaces) and try again
|
|
||||||
normalized = re.sub(r"[-_]+", " ", raw_type).strip().title()
|
|
||||||
canonical = PROPERTY_TYPE_MAP.get(normalized)
|
|
||||||
if canonical:
|
|
||||||
return canonical
|
|
||||||
# Keyword fallback
|
|
||||||
lower = raw_type.lower()
|
|
||||||
if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower or "penthouse" in lower:
|
|
||||||
return "Flats/Maisonettes"
|
|
||||||
if "semi" in lower and "detach" in lower:
|
|
||||||
return "Semi-Detached"
|
|
||||||
if "detach" in lower:
|
|
||||||
return "Detached"
|
|
||||||
if "terrace" in lower or "mews" in lower:
|
|
||||||
return "Terraced"
|
|
||||||
if "house" in lower:
|
|
||||||
return "Detached"
|
|
||||||
return "Other"
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_rent_frequency(price_text: str) -> str:
|
|
||||||
"""Detect rent frequency from Zoopla price text.
|
|
||||||
|
|
||||||
Zoopla price elements contain text like '£1,500 pcm', '£350 pw',
|
|
||||||
'£18,000 pa'. Defaults to 'monthly' if no frequency indicator found.
|
|
||||||
|
|
||||||
Checks monthly indicators (pcm) BEFORE weekly (pw) because Zoopla cards
|
|
||||||
often display both monthly and weekly prices in the same text. When the
|
|
||||||
JS extraction falls back to full card text, checking pcm first ensures
|
|
||||||
the captured monthly price gets the correct frequency label.
|
|
||||||
"""
|
|
||||||
lower = price_text.lower()
|
|
||||||
if "pcm" in lower or "per month" in lower or "per calendar month" in lower:
|
|
||||||
return "monthly"
|
|
||||||
if "pw" in lower or "per week" in lower or "/w" in lower:
|
|
||||||
return "weekly"
|
|
||||||
if "pa" in lower or "per annum" in lower or "/y" in lower or "per year" in lower:
|
|
||||||
return "yearly"
|
|
||||||
# No indicator — default monthly (Zoopla standard)
|
|
||||||
return "monthly"
|
|
||||||
|
|
||||||
|
|
||||||
def transform_property(
|
|
||||||
raw: dict,
|
|
||||||
channel: str,
|
|
||||||
pc_index: PostcodeSpatialIndex,
|
|
||||||
pc_coords: dict[str, tuple[float, float]],
|
|
||||||
search_outcode: str | None = None,
|
|
||||||
) -> dict | None:
|
|
||||||
"""Transform a raw Zoopla listing dict into the standard output schema.
|
|
||||||
|
|
||||||
Zoopla search cards do not include coordinates, so we resolve lat/lng
|
|
||||||
from postcodes extracted from the address text."""
|
|
||||||
price = raw.get("price")
|
|
||||||
if not price or int(price) <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
address = raw.get("address", "")
|
|
||||||
|
|
||||||
# Resolve postcode and coordinates from address
|
|
||||||
postcode = _extract_postcode(address)
|
|
||||||
lat = lng = None
|
|
||||||
|
|
||||||
if postcode:
|
|
||||||
coords = pc_coords.get(postcode)
|
|
||||||
if coords:
|
|
||||||
lat, lng = coords
|
|
||||||
|
|
||||||
if lat is None:
|
|
||||||
# Try outcode-level fallback from address text
|
|
||||||
addr_outcode = _extract_outcode(address)
|
|
||||||
if addr_outcode:
|
|
||||||
result = _resolve_outcode_coords(addr_outcode, pc_coords)
|
|
||||||
if result:
|
|
||||||
postcode, lat, lng = result
|
|
||||||
|
|
||||||
# Final fallback: use the outcode we know we're searching
|
|
||||||
if lat is None and search_outcode:
|
|
||||||
result = _resolve_outcode_coords(search_outcode, pc_coords)
|
|
||||||
if result:
|
|
||||||
postcode, lat, lng = result
|
|
||||||
|
|
||||||
if lat is None or lng is None or not postcode:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Validate coordinates are in England
|
|
||||||
if not (49 <= lat <= 56 and -7 <= lng <= 2):
|
|
||||||
return None
|
|
||||||
|
|
||||||
raw_beds = raw.get("beds") or 0
|
|
||||||
raw_baths = raw.get("baths") or 0
|
|
||||||
bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0
|
|
||||||
bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0
|
|
||||||
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
|
|
||||||
log.warning(
|
|
||||||
"Zoopla %s: implausible beds=%d baths=%d (capped to 0)",
|
|
||||||
raw.get("id", "?"), raw_beds, raw_baths,
|
|
||||||
)
|
|
||||||
receptions = raw.get("receptions") or 0
|
|
||||||
|
|
||||||
# Floor area: convert sq ft to sq m
|
|
||||||
floor_area_sqm = None
|
|
||||||
sqft = raw.get("floor_area_sqft")
|
|
||||||
if sqft:
|
|
||||||
floor_area_sqm = validate_floor_area(round(sqft * 0.092903, 1))
|
|
||||||
|
|
||||||
listing_id = raw.get("id", "")
|
|
||||||
listing_url = raw.get("url", "")
|
|
||||||
if listing_url and not listing_url.startswith("http"):
|
|
||||||
listing_url = ZOOPLA_BASE + listing_url
|
|
||||||
|
|
||||||
# Detect rent frequency from price text (e.g. "£1,500 pcm" vs "£350 pw")
|
|
||||||
if channel == "BUY":
|
|
||||||
frequency = ""
|
|
||||||
else:
|
|
||||||
price_text = raw.get("price_text", "")
|
|
||||||
frequency = _detect_rent_frequency(price_text)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": f"zp_{listing_id}",
|
|
||||||
"Bedrooms": bedrooms,
|
|
||||||
"Bathrooms": bathrooms,
|
|
||||||
"Number of bedrooms & living rooms": bedrooms + receptions,
|
|
||||||
"lon": lng,
|
|
||||||
"lat": lat,
|
|
||||||
"Postcode": postcode,
|
|
||||||
"Address per Property Register": address,
|
|
||||||
"Leasehold/Freehold": raw.get("tenure") or None,
|
|
||||||
"Property type": _map_property_type(raw.get("property_type")),
|
|
||||||
"Property sub-type": normalize_sub_type(raw.get("property_type")),
|
|
||||||
"price": int(price),
|
|
||||||
"price_frequency": frequency,
|
|
||||||
"Price qualifier": "",
|
|
||||||
"Total floor area (sqm)": floor_area_sqm,
|
|
||||||
"Listing URL": listing_url,
|
|
||||||
"Listing features": [],
|
|
||||||
"first_visible_date": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Top-level search function (called by scraper.py)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def search_outcode(
|
|
||||||
page,
|
|
||||||
outcode: str,
|
|
||||||
channel: str,
|
|
||||||
pc_index: PostcodeSpatialIndex,
|
|
||||||
pc_coords: dict[str, tuple[float, float]],
|
|
||||||
base_search_url: str | None = None,
|
|
||||||
) -> tuple[list[dict], str | None]:
|
|
||||||
"""Search Zoopla for properties in one outcode.
|
|
||||||
|
|
||||||
Takes a live Camoufox Page (from launch_browser). Navigates through the
|
|
||||||
search flow, extracts listings from rendered DOM, and transforms to the
|
|
||||||
standard output schema.
|
|
||||||
|
|
||||||
If base_search_url is provided (from a previous channel search for the same
|
|
||||||
outcode), tries direct URL navigation first — skipping the slow homepage
|
|
||||||
search flow. Falls back to full navigation if direct fails.
|
|
||||||
|
|
||||||
Returns (properties, search_url) where search_url can be passed to the next
|
|
||||||
channel call for this outcode.
|
|
||||||
|
|
||||||
Raises TurnstileError if Cloudflare blocks us mid-session.
|
|
||||||
"""
|
|
||||||
navigated = False
|
|
||||||
if base_search_url:
|
|
||||||
navigated = _navigate_direct(page, base_search_url)
|
|
||||||
if navigated:
|
|
||||||
log.debug("Zoopla %s %s: used direct URL navigation", outcode, channel)
|
|
||||||
|
|
||||||
if not navigated:
|
|
||||||
if not _navigate_search(page, outcode, channel):
|
|
||||||
return [], None
|
|
||||||
|
|
||||||
total_results = _get_result_count(page)
|
|
||||||
|
|
||||||
# Always try extraction even if result count is 0 — the count regex may
|
|
||||||
# not match Zoopla's current text format, but listings may still be in DOM
|
|
||||||
raw_listings = _paginate(page, max(total_results, 25), channel)
|
|
||||||
if not raw_listings:
|
|
||||||
if total_results > 0:
|
|
||||||
log.debug(
|
|
||||||
"Zoopla %s %s: page claims %d results but extraction found 0 — "
|
|
||||||
"DOM selectors may need updating",
|
|
||||||
outcode, channel, total_results,
|
|
||||||
)
|
|
||||||
return [], None
|
|
||||||
|
|
||||||
channel_label = "buy" if channel == "BUY" else "rent"
|
|
||||||
properties = []
|
|
||||||
dropped = 0
|
|
||||||
for raw in raw_listings:
|
|
||||||
transformed = transform_property(raw, channel, pc_index, pc_coords, search_outcode=outcode)
|
|
||||||
if transformed:
|
|
||||||
properties.append(transformed)
|
|
||||||
zoopla_properties_scraped.labels(channel=channel_label).inc()
|
|
||||||
else:
|
|
||||||
dropped += 1
|
|
||||||
|
|
||||||
if dropped and not properties:
|
|
||||||
# Log a sample raw listing to diagnose which fields are missing
|
|
||||||
sample = raw_listings[0] if raw_listings else {}
|
|
||||||
log.debug(
|
|
||||||
"Zoopla %s %s: extracted %d raw listings but all %d dropped in transform "
|
|
||||||
"(no price/postcode/coords). Sample raw: price=%s address=%r",
|
|
||||||
outcode, channel, len(raw_listings), dropped,
|
|
||||||
sample.get("price"), sample.get("address", ""),
|
|
||||||
)
|
|
||||||
elif dropped > len(raw_listings) // 2:
|
|
||||||
log.debug(
|
|
||||||
"Zoopla %s %s: %d/%d listings dropped in transform",
|
|
||||||
outcode, channel, dropped, len(raw_listings),
|
|
||||||
)
|
|
||||||
|
|
||||||
return properties, page.url
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
#!/usr/bin/env -S uv run --project ../finder
|
|
||||||
"""Zoopla scraping experiment — working prototype using Camoufox.
|
|
||||||
|
|
||||||
Key findings:
|
|
||||||
- Zoopla uses Cloudflare Turnstile (managed interactive challenge)
|
|
||||||
- Playwright headless Chromium + stealth patches CANNOT beat it
|
|
||||||
- Camoufox (anti-fingerprinting Firefox fork) PASSES Cloudflare
|
|
||||||
- Zoopla uses Next.js App Router with React Server Components (RSC)
|
|
||||||
- Listing data is NOT in __NEXT_DATA__ — it's server-rendered in RSC stream
|
|
||||||
- URL-based location slugs (e.g. /properties/london/) return 0 results
|
|
||||||
- Must use the search autocomplete (GraphQL: getGeoSuggestion) to resolve
|
|
||||||
a location, then submit the form to get results
|
|
||||||
- GraphQL endpoint: api-graphql-lambda.prod.zoopla.co.uk/graphql
|
|
||||||
- Listings loaded via getTopLeadListingIds + getRareFindLeadListingIds ops
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
uv run --project finder scripts/zoopla_experiment.py [LOCATION]
|
|
||||||
uv run --project finder scripts/zoopla_experiment.py "Tower Hamlets"
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s %(levelname)-8s %(message)s",
|
|
||||||
datefmt="%H:%M:%S",
|
|
||||||
)
|
|
||||||
log = logging.getLogger("zoopla-exp")
|
|
||||||
|
|
||||||
|
|
||||||
def scrape_zoopla(location: str = "London", channel: str = "BUY"):
|
|
||||||
from camoufox.sync_api import Camoufox
|
|
||||||
|
|
||||||
tab_label = "Buy" if channel == "BUY" else "Rent"
|
|
||||||
log.info("Scraping Zoopla: location=%s channel=%s", location, channel)
|
|
||||||
|
|
||||||
with Camoufox(headless=True) as browser:
|
|
||||||
page = browser.new_page()
|
|
||||||
|
|
||||||
# Intercept GraphQL responses
|
|
||||||
graphql_responses = []
|
|
||||||
|
|
||||||
def on_resp(response):
|
|
||||||
url = response.url
|
|
||||||
ct = response.headers.get("content-type", "")
|
|
||||||
if "json" in ct and "graphql" in url:
|
|
||||||
try:
|
|
||||||
body = response.json()
|
|
||||||
req = response.request.post_data or ""
|
|
||||||
graphql_responses.append({"body": body, "req": req})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
page.on("response", on_resp)
|
|
||||||
|
|
||||||
# Step 1: Load homepage and pass Cloudflare
|
|
||||||
log.info("Loading Zoopla homepage...")
|
|
||||||
page.goto("https://www.zoopla.co.uk/", wait_until="domcontentloaded", timeout=60000)
|
|
||||||
|
|
||||||
for i in range(20):
|
|
||||||
if "Just a moment" not in page.title():
|
|
||||||
break
|
|
||||||
time.sleep(3)
|
|
||||||
else:
|
|
||||||
log.error("Cloudflare did not resolve after 60s")
|
|
||||||
return []
|
|
||||||
|
|
||||||
log.info("Homepage loaded: %s", page.title())
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
# Step 2: Dismiss cookie consent (shadow DOM)
|
|
||||||
page.evaluate("""() => {
|
|
||||||
const aside = document.querySelector('#usercentrics-cmp-ui');
|
|
||||||
if (aside && aside.shadowRoot) {
|
|
||||||
const btns = aside.shadowRoot.querySelectorAll('button');
|
|
||||||
for (const btn of btns) {
|
|
||||||
if (btn.innerText.includes('Accept')) { btn.click(); return; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
aside?.remove();
|
|
||||||
}""")
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
# Step 3: Select Buy/Rent tab if needed
|
|
||||||
if channel == "RENT":
|
|
||||||
rent_tab = page.query_selector('button:has-text("Rent")') or page.query_selector(f'[role="tab"]:has-text("{tab_label}")')
|
|
||||||
if rent_tab:
|
|
||||||
rent_tab.click()
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
# Step 4: Type location into search and select autocomplete suggestion
|
|
||||||
log.info("Searching for '%s'...", location)
|
|
||||||
search_input = (
|
|
||||||
page.query_selector('input[name="autosuggest-input"]')
|
|
||||||
or page.query_selector('input[type="text"]')
|
|
||||||
)
|
|
||||||
if not search_input:
|
|
||||||
log.error("Could not find search input")
|
|
||||||
return []
|
|
||||||
|
|
||||||
search_input.click()
|
|
||||||
time.sleep(0.5)
|
|
||||||
search_input.fill("") # Clear any existing text
|
|
||||||
search_input.type(location, delay=80)
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
# Select first autocomplete suggestion
|
|
||||||
first_option = page.query_selector('[role="option"]')
|
|
||||||
if first_option:
|
|
||||||
suggestion_text = first_option.inner_text()
|
|
||||||
log.info("Selecting suggestion: %s", suggestion_text)
|
|
||||||
first_option.click()
|
|
||||||
time.sleep(1)
|
|
||||||
else:
|
|
||||||
log.warning("No autocomplete suggestions appeared")
|
|
||||||
|
|
||||||
# Step 5: Submit search
|
|
||||||
search_btn = page.query_selector('button:has-text("Search")')
|
|
||||||
if search_btn:
|
|
||||||
search_btn.click()
|
|
||||||
else:
|
|
||||||
search_input.press("Enter")
|
|
||||||
|
|
||||||
log.info("Waiting for results...")
|
|
||||||
time.sleep(10)
|
|
||||||
|
|
||||||
final_url = page.url
|
|
||||||
final_title = page.title()
|
|
||||||
log.info("URL: %s", final_url)
|
|
||||||
log.info("Title: %s", final_title)
|
|
||||||
|
|
||||||
# Step 6: Extract listings from rendered DOM
|
|
||||||
listings = page.evaluate(r"""() => {
|
|
||||||
const links = Array.from(document.querySelectorAll(
|
|
||||||
'a[href*="/for-sale/details/"], a[href*="/new-homes/details/"], a[href*="/to-rent/details/"]'
|
|
||||||
));
|
|
||||||
|
|
||||||
const seen = new Set();
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
for (const link of links) {
|
|
||||||
const href = link.href;
|
|
||||||
const match = href.match(/\/details\/(\d+)\//);
|
|
||||||
if (!match) continue;
|
|
||||||
|
|
||||||
const id = match[1];
|
|
||||||
if (seen.has(id)) continue;
|
|
||||||
seen.add(id);
|
|
||||||
|
|
||||||
// Walk up to find the listing card container
|
|
||||||
let card = link;
|
|
||||||
for (let j = 0; j < 10; j++) {
|
|
||||||
card = card.parentElement;
|
|
||||||
if (!card) break;
|
|
||||||
const text = card.innerText || '';
|
|
||||||
// A listing card should have a price and at least beds or area
|
|
||||||
if (text.includes('£') && (text.includes('bed') || text.includes('sq ft'))) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!card) continue;
|
|
||||||
|
|
||||||
const text = card.innerText || '';
|
|
||||||
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
|
||||||
|
|
||||||
const priceMatch = text.match(/£([\d,]+)/);
|
|
||||||
const bedsMatch = text.match(/(\d+)\s*beds?/i);
|
|
||||||
const bathsMatch = text.match(/(\d+)\s*baths?/i);
|
|
||||||
const recMatch = text.match(/(\d+)\s*reception/i);
|
|
||||||
const areaMatch = text.match(/([\d,]+)\s*sq\s*ft/i);
|
|
||||||
|
|
||||||
// Try to find address — usually a line with a postcode or comma-separated location
|
|
||||||
let address = '';
|
|
||||||
for (const line of lines) {
|
|
||||||
if (/[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}/i.test(line) ||
|
|
||||||
(line.includes(',') && !line.includes('£') && !line.match(/^\d+ beds?/i))) {
|
|
||||||
address = line;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tenure
|
|
||||||
let tenure = '';
|
|
||||||
if (/freehold/i.test(text)) tenure = 'Freehold';
|
|
||||||
else if (/leasehold/i.test(text)) tenure = 'Leasehold';
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
id: id,
|
|
||||||
url: href.replace(window.location.origin, ''),
|
|
||||||
price: priceMatch ? parseInt(priceMatch[1].replace(/,/g, '')) : null,
|
|
||||||
beds: bedsMatch ? parseInt(bedsMatch[1]) : null,
|
|
||||||
baths: bathsMatch ? parseInt(bathsMatch[1]) : null,
|
|
||||||
receptions: recMatch ? parseInt(recMatch[1]) : null,
|
|
||||||
floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
|
|
||||||
address: address,
|
|
||||||
tenure: tenure,
|
|
||||||
text_preview: lines.slice(0, 10).join(' | '),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}""")
|
|
||||||
|
|
||||||
log.info("Extracted %d unique listings from page 1", len(listings))
|
|
||||||
|
|
||||||
# Step 7: Check for results count and pagination
|
|
||||||
body_text = page.inner_text("body")
|
|
||||||
count_match = re.search(r"([\d,]+)\s+results?", body_text)
|
|
||||||
total_results = int(count_match.group(1).replace(",", "")) if count_match else len(listings)
|
|
||||||
log.info("Total results: %d", total_results)
|
|
||||||
|
|
||||||
# Step 8: Log GraphQL operations we saw
|
|
||||||
log.info("GraphQL operations intercepted:")
|
|
||||||
for gql in graphql_responses:
|
|
||||||
try:
|
|
||||||
req = json.loads(gql["req"])
|
|
||||||
op = req.get("operationName", "?")
|
|
||||||
log.info(" - %s", op)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Step 9: Extract cookies for potential curl_cffi reuse
|
|
||||||
cookies = page.context.cookies()
|
|
||||||
session_cookies = {
|
|
||||||
c["name"]: c["value"]
|
|
||||||
for c in cookies
|
|
||||||
if "zoopla" in c.get("domain", "") or "cf" in c.get("name", "").lower()
|
|
||||||
}
|
|
||||||
ua = page.evaluate("navigator.userAgent")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"url": final_url,
|
|
||||||
"title": final_title,
|
|
||||||
"total_results": total_results,
|
|
||||||
"listings": listings,
|
|
||||||
"cookies": session_cookies,
|
|
||||||
"user_agent": ua,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
location = sys.argv[1] if len(sys.argv) > 1 else "London"
|
|
||||||
|
|
||||||
result = scrape_zoopla(location, channel="BUY")
|
|
||||||
if not result:
|
|
||||||
log.error("Scraping failed")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
listings = result["listings"]
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f" Zoopla: {result['title']}")
|
|
||||||
print(f" URL: {result['url']}")
|
|
||||||
print(f" Total: {result['total_results']} results, {len(listings)} extracted")
|
|
||||||
print(f"{'='*60}\n")
|
|
||||||
|
|
||||||
for i, listing in enumerate(listings):
|
|
||||||
print(f"--- Listing {i+1}: {listing['url']} ---")
|
|
||||||
display = {k: v for k, v in listing.items() if k != "text_preview" and v}
|
|
||||||
print(json.dumps(display, indent=2, ensure_ascii=False))
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Summary stats
|
|
||||||
prices = [item["price"] for item in listings if item["price"]]
|
|
||||||
beds = [item["beds"] for item in listings if item["beds"]]
|
|
||||||
if prices:
|
|
||||||
print(f"Price range: £{min(prices):,} - £{max(prices):,}")
|
|
||||||
print(f"Median: £{sorted(prices)[len(prices)//2]:,}")
|
|
||||||
if beds:
|
|
||||||
print(f"Bedrooms: {min(beds)}-{max(beds)}")
|
|
||||||
|
|
||||||
# Cookie info for reuse
|
|
||||||
print(f"\nSession cookies ({len(result['cookies'])} cookies)")
|
|
||||||
print(f"User-Agent: {result['user_agent']}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::{IntoResponse, Json, Response};
|
|
||||||
use serde_json::json;
|
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
use crate::consts::GRID_CELL_SIZE;
|
|
||||||
use crate::data::{self, PropertyData};
|
|
||||||
use crate::metrics::record_data_stats;
|
|
||||||
use crate::routes::{build_features_response, build_system_prompt};
|
|
||||||
use crate::state::{AppState, SharedState};
|
|
||||||
use crate::utils::GridIndex;
|
|
||||||
|
|
||||||
pub async fn post_reload(State(shared): State<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),
|
|
||||||
outcode_data: Arc::clone(&old.outcode_data),
|
|
||||||
poi_category_groups: Arc::clone(&old.poi_category_groups),
|
|
||||||
travel_time_store: Arc::clone(&old.travel_time_store),
|
|
||||||
token_cache: Arc::clone(&old.token_cache),
|
|
||||||
superuser_token_cache: Arc::clone(&old.superuser_token_cache),
|
|
||||||
|
|
||||||
// Config (cheap clone)
|
|
||||||
screenshot_url: old.screenshot_url.clone(),
|
|
||||||
public_url: old.public_url.clone(),
|
|
||||||
is_dev: old.is_dev,
|
|
||||||
index_html: old.index_html.clone(),
|
|
||||||
http_client: old.http_client.clone(),
|
|
||||||
pocketbase_url: old.pocketbase_url.clone(),
|
|
||||||
pocketbase_admin_email: old.pocketbase_admin_email.clone(),
|
|
||||||
pocketbase_admin_password: old.pocketbase_admin_password.clone(),
|
|
||||||
gemini_api_key: old.gemini_api_key.clone(),
|
|
||||||
gemini_model: old.gemini_model.clone(),
|
|
||||||
google_maps_api_key: old.google_maps_api_key.clone(),
|
|
||||||
stripe_secret_key: old.stripe_secret_key.clone(),
|
|
||||||
stripe_webhook_secret: old.stripe_webhook_secret.clone(),
|
|
||||||
stripe_referral_coupon_id: old.stripe_referral_coupon_id.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 8. Atomic swap
|
|
||||||
shared.swap_state(new_state);
|
|
||||||
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
info!(
|
|
||||||
rows = row_count,
|
|
||||||
features = feature_count,
|
|
||||||
elapsed_ms = elapsed.as_millis(),
|
|
||||||
"Reload complete"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((row_count, feature_count, elapsed.as_millis()))
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue