Hide POIs, show overlay state, remove zoom button, rotate vpn
This commit is contained in:
parent
35276d34fa
commit
bce34b73de
32 changed files with 1137 additions and 186 deletions
109
finder/zoopla.py
109
finder/zoopla.py
|
|
@ -27,14 +27,11 @@ import time
|
|||
from pathlib import Path
|
||||
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
|
||||
import gluetun
|
||||
import shutdown
|
||||
from constants import (
|
||||
DATA_DIR,
|
||||
DELAY_BETWEEN_PAGES,
|
||||
GLUETUN_API_KEY,
|
||||
GLUETUN_CONTROL_URL,
|
||||
GLUETUN_MAX_ROTATIONS,
|
||||
GLUETUN_PROXY,
|
||||
MAX_BEDROOMS,
|
||||
|
|
@ -472,110 +469,16 @@ def _challenge_timeout_seconds() -> int:
|
|||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# When Cloudflare Turnstile fires mid-scrape, the cheapest unblocker is to
|
||||
# swap the egress IP via Gluetun's HTTP control server. We stop and re-start
|
||||
# the VPN, poll until the public IP changes, drop the stale cf_clearance
|
||||
# cookies (bound to the previous IP), then reload and re-check the challenge.
|
||||
|
||||
|
||||
def _gluetun_base_url() -> str:
|
||||
return GLUETUN_CONTROL_URL.rstrip("/")
|
||||
|
||||
|
||||
def _gluetun_api_key() -> str | None:
|
||||
return GLUETUN_API_KEY
|
||||
# swap the egress IP via Gluetun's HTTP control server, then drop the stale
|
||||
# cf_clearance cookies (bound to the previous IP) and re-check the challenge.
|
||||
# The control-server plumbing itself lives in gluetun.py, shared with
|
||||
# http_client.py's egress-block detection.
|
||||
|
||||
|
||||
def _gluetun_max_rotations() -> int:
|
||||
return max(GLUETUN_MAX_ROTATIONS, 0)
|
||||
|
||||
|
||||
def _gluetun_client() -> httpx.Client:
|
||||
# Talks to the control server directly (not through the VPN proxy).
|
||||
headers = {}
|
||||
api_key = _gluetun_api_key()
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
return httpx.Client(headers=headers)
|
||||
|
||||
|
||||
def _gluetun_public_ip(client: httpx.Client) -> str | None:
|
||||
try:
|
||||
resp = client.get(f"{_gluetun_base_url()}/v1/publicip/ip", timeout=5.0)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return None
|
||||
return data.get("public_ip") or data.get("ip")
|
||||
|
||||
|
||||
def _gluetun_set_vpn_status(client: httpx.Client, status: str) -> bool:
|
||||
"""PUT /v1/vpn/status with {'status': status}. Returns True on 2xx."""
|
||||
try:
|
||||
resp = client.put(
|
||||
f"{_gluetun_base_url()}/v1/vpn/status",
|
||||
json={"status": status},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
log.warning("Gluetun vpn/status %s failed: %s", status, exc)
|
||||
return False
|
||||
if resp.status_code == 401:
|
||||
log.warning(
|
||||
"Gluetun vpn/status %s: 401 Unauthorized. The API key must be "
|
||||
"authorised for 'PUT /v1/vpn/status' in Gluetun's auth config.toml",
|
||||
status,
|
||||
)
|
||||
return False
|
||||
if resp.status_code >= 400:
|
||||
log.warning(
|
||||
"Gluetun vpn/status %s returned HTTP %d: %s",
|
||||
status, resp.status_code, resp.text[:200],
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _rotate_gluetun_ip(wait_seconds: int = 45) -> bool:
|
||||
"""Restart Gluetun's VPN and wait for the public IP to change.
|
||||
|
||||
Returns True if a new IP was observed within wait_seconds."""
|
||||
with _gluetun_client() as client:
|
||||
old_ip = _gluetun_public_ip(client)
|
||||
log.info("Requesting Gluetun IP rotation (current IP: %s)", old_ip or "unknown")
|
||||
|
||||
stop_attempted = False
|
||||
restart_confirmed = False
|
||||
try:
|
||||
stop_attempted = True
|
||||
if not _gluetun_set_vpn_status(client, "stopped"):
|
||||
return False
|
||||
time.sleep(2)
|
||||
restart_confirmed = _gluetun_set_vpn_status(client, "running")
|
||||
if not restart_confirmed:
|
||||
return False
|
||||
|
||||
deadline = time.monotonic() + wait_seconds
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(2)
|
||||
new_ip = _gluetun_public_ip(client)
|
||||
if new_ip and new_ip != old_ip:
|
||||
log.info("Gluetun rotated IP: %s -> %s", old_ip or "?", new_ip)
|
||||
return True
|
||||
finally:
|
||||
if stop_attempted and not restart_confirmed:
|
||||
log.warning(
|
||||
"Gluetun VPN may be stopped after failed rotation; attempting recovery start"
|
||||
)
|
||||
if not _gluetun_set_vpn_status(client, "running"):
|
||||
log.error(
|
||||
"Gluetun VPN recovery start failed; manual intervention required"
|
||||
)
|
||||
|
||||
log.warning("Gluetun IP did not change within %ds", wait_seconds)
|
||||
return False
|
||||
|
||||
|
||||
def _clear_cloudflare_cookies(page) -> None:
|
||||
"""Drop cf_clearance / __cf_bm which are bound to the previous egress IP."""
|
||||
try:
|
||||
|
|
@ -596,7 +499,7 @@ def _rotate_and_retry_challenge(page, max_rotations: int) -> bool:
|
|||
"Cloudflare Turnstile challenge, rotating Gluetun IP (attempt %d/%d)",
|
||||
attempt, max_rotations,
|
||||
)
|
||||
if not _rotate_gluetun_ip():
|
||||
if not gluetun.rotate_ip():
|
||||
continue
|
||||
|
||||
_clear_cloudflare_cookies(page)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue