Add Fresh Harvest integration scaffold
Implements the freshharvest.com two-step login handshake, config flow, 6-hour polling coordinator, and four delivery sensors. Portal page parsing is not implemented; async_get_next_delivery raises until the account HTML is mapped against a signed-in session.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"""HTTP client for the freshharvest.com customer portal.
|
||||
|
||||
The site is server-rendered (no JSON API), so this client drives the same form
|
||||
flow a browser does and parses HTML out the other side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
import aiohttp
|
||||
from yarl import URL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
BASE = URL("https://freshharvest.com")
|
||||
LOGIN_FORM = "/s/popup/login"
|
||||
LOGIN_SUBMIT = "/s/submit/login"
|
||||
|
||||
# Browser UA: the portal serves a reduced/blocked page to obvious scripts.
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/126.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# Hidden anti-replay fields, minted fresh on every GET of the login form and
|
||||
# only valid for the session cookie they were issued against.
|
||||
_HIDDEN_RE = re.compile(
|
||||
r"name='(?P<name>LoginSecurity|SubmitToken)'[^>]*value='(?P<value>[^']*)'"
|
||||
)
|
||||
|
||||
|
||||
class FreshHarvestError(Exception):
|
||||
"""Base error."""
|
||||
|
||||
|
||||
class FreshHarvestAuthError(FreshHarvestError):
|
||||
"""Credentials rejected, or the session expired and could not be renewed."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Delivery:
|
||||
"""A scheduled delivery."""
|
||||
|
||||
delivery_date: date | None = None
|
||||
window: str | None = None
|
||||
status: str | None = None
|
||||
total: float | None = None
|
||||
items: list[str] = field(default_factory=list)
|
||||
cutoff: str | None = None
|
||||
|
||||
|
||||
class FreshHarvestClient:
|
||||
"""Session-holding client for one portal account."""
|
||||
|
||||
def __init__(
|
||||
self, session: aiohttp.ClientSession, email: str, password: str
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._email = email
|
||||
self._password = password
|
||||
self._authenticated = False
|
||||
|
||||
async def _get(self, path: str) -> str:
|
||||
async with self._session.get(
|
||||
BASE.join(URL(path)), headers={"User-Agent": USER_AGENT}
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
|
||||
async def async_login(self) -> None:
|
||||
"""Run the two-step handshake: fetch tokens, then post credentials.
|
||||
|
||||
The tokens are bound to the session cookie issued by the same GET, so
|
||||
the fetch and the post cannot be split across sessions or cached.
|
||||
"""
|
||||
form = await self._get(LOGIN_FORM)
|
||||
hidden = {m["name"]: m["value"] for m in _HIDDEN_RE.finditer(form)}
|
||||
if len(hidden) != 2:
|
||||
raise FreshHarvestError(
|
||||
f"login form missing anti-replay tokens (got {sorted(hidden)}); "
|
||||
"the portal markup likely changed"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"LoginEmail": self._email,
|
||||
"LoginPassword": self._password,
|
||||
"LoginSecurity": hidden["LoginSecurity"],
|
||||
"SubmitToken": hidden["SubmitToken"],
|
||||
"Redirect": "",
|
||||
}
|
||||
async with self._session.post(
|
||||
BASE.join(URL(LOGIN_SUBMIT)),
|
||||
data=payload,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
body = await resp.text()
|
||||
|
||||
if not self._looks_authenticated(body):
|
||||
raise FreshHarvestAuthError("login rejected")
|
||||
self._authenticated = True
|
||||
|
||||
@staticmethod
|
||||
def _looks_authenticated(body: str) -> bool:
|
||||
"""Distinguish a good login from a rejected one.
|
||||
|
||||
NOTE: provisional. Every /p/* path returns 200 even for nonsense URLs,
|
||||
so an HTTP status is not a signal here. Needs confirming against a real
|
||||
authenticated response before this integration can be trusted.
|
||||
"""
|
||||
lowered = body.lower()
|
||||
if "invalid" in lowered or "incorrect" in lowered:
|
||||
return False
|
||||
return "sign out" in lowered or "log out" in lowered
|
||||
|
||||
async def async_get_next_delivery(self) -> Delivery:
|
||||
"""Return the account's next scheduled delivery.
|
||||
|
||||
UNIMPLEMENTED. The portal serves a catch-all 200 for every /p/* path
|
||||
when signed out, so the real account pages could not be located or
|
||||
parsed without an authenticated session. Fill this in against a live
|
||||
session rather than guessing at selectors.
|
||||
"""
|
||||
raise FreshHarvestError(
|
||||
"delivery parsing is not implemented yet: the portal HTML has not "
|
||||
"been mapped against a signed-in session"
|
||||
)
|
||||
|
||||
async def async_fetch(self, path: str) -> str:
|
||||
"""Fetch a portal page, re-authenticating once if the session lapsed."""
|
||||
if not self._authenticated:
|
||||
await self.async_login()
|
||||
body = await self._get(path)
|
||||
if not self._looks_authenticated(body):
|
||||
self._authenticated = False
|
||||
await self.async_login()
|
||||
body = await self._get(path)
|
||||
if not self._looks_authenticated(body):
|
||||
raise FreshHarvestAuthError(f"could not hold a session for {path}")
|
||||
return body
|
||||
Reference in New Issue
Block a user