from__future__importannotationsimportjsonimporturllib.parsefromtypingimportAnyimporthrequestsfromrequestsimportRequestfromdataclassesimportdataclass,fieldimportosfrom.endpoints.advertisingimportClassAdvertisingfrom.endpoints.catalogimportClassCatalogfrom.endpoints.generalimportClassGeneralfrom.endpoints.geolocationimportClassGeolocation# ---------------------------------------------------------------------------# Константы# ---------------------------------------------------------------------------CATALOG_VERSION="1.4.1.0"MAIN_SITE_URL="https://www.perekrestok.ru"CATALOG_URL=f"{MAIN_SITE_URL}/api/customer/{CATALOG_VERSION}"# ---------------------------------------------------------------------------# Главный клиент# ---------------------------------------------------------------------------def_pick_https_proxy()->str|None:"""Возвращает прокси из HTTPS_PROXY/https_proxy (если заданы)."""returnos.getenv("HTTPS_PROXY")oros.getenv("https_proxy")
[docs]@dataclassclassPerekrestokAPI:"""Клиент Перекрёстка. Attributes ---------- Geolocation : ClassGeolocation Клиент геолокации. Catalog : ClassCatalog Методы каталога. Advertising : ClassAdvertising Методы рекламы. General : ClassGeneral Общие методы (например, для формы обратной связи). """timeout:float=15.0browser:str="firefox"headless:bool=Trueproxy:str|None=field(default_factory=_pick_https_proxy)browser_opts:dict[str,Any]=field(default_factory=dict)# будет создана в __post_init__session:hrequests.Session=field(init=False,repr=False)# ───── lifecycle ─────def__post_init__(self)->None:self.session=hrequests.Session(self.browser,timeout=self.timeout,proxy=self.proxy,# ← автоподхват из env, если есть)self.access_token=self.access_token# применит setterself.Geolocation=ClassGeolocation(self,CATALOG_URL)self.Catalog=ClassCatalog(self,CATALOG_URL)self.Advertising=ClassAdvertising(self,CATALOG_URL)self.General=ClassGeneral(self,CATALOG_URL)def__enter__(self):"""Вход в контекстный менеджер с автоматическим прогревом сессии."""self._warmup()returnselfdef__exit__(self,*exc):"""Выход из контекстного менеджера с закрытием сессии."""self.close()
[docs]defclose(self):"""Закрыть HTTP-сессию и освободить ресурсы."""self.session.close()
# property setget access_token@propertydefaccess_token(self)->str|None:"""Токен доступа, который будет использоваться в запросах."""token=self.session.headers.get("Auth",None)iftoken:ifnottoken.startswith("Bearer "):raiseValueError("Access token must start with 'Bearer '.")token=token.removeprefix("Bearer ")returntoken@access_token.setterdefaccess_token(self,token:str|None)->None:"""Установить токен доступа для использования в запросах."""iftokenisnotNoneandnotisinstance(token,str):raiseTypeError("Access token must be a string or None.")iftokenisNone:self.session.headers.pop("Auth",None)else:self.session.headers.update({# токен пойдёт в каждый запрос"Auth":f"Bearer {token}"})# Прогрев сессии (headless ➜ cookie `session` ➜ accessToken)def_warmup(self)->None:"""Прогрев сессии через браузер для получения токена доступа. Открывает главную страницу сайта в headless браузере, получает cookie сессии и извлекает из неё access token для последующих API запросов. """ifself.access_tokenisNone:withhrequests.BrowserSession(session=self.session,browser=self.browser,headless=self.headless,**self.browser_opts,)aspage:page.goto(MAIN_SITE_URL)page.awaitSelector("#app",timeout=self.timeout)if"session"notinself.session.cookies:raiseRuntimeError("Cookie 'session' not found after warmup.")raw=urllib.parse.unquote(self.session.cookies["session"])clean=json.loads(raw.removeprefix("j:"))self.access_token=clean['accessToken']def_request(self,method:str,url:str,*,json_body:Any|None=None,)->hrequests.Response:"""Выполнить HTTP-запрос через внутреннюю сессию. Единая точка входа для всех HTTP-запросов библиотеки. Добавляет к ответу объект Request для совместимости. Args: method: HTTP метод (GET, POST, PUT, DELETE и т.д.) url: URL для запроса json_body: Тело запроса в формате JSON (опционально) """# Единая точка входа в чужую библиотеку для удобстваresp=self.session.request(method.upper(),url,json=json_body,timeout=self.timeout)ifhasattr(resp,"request"):raiseRuntimeError("Response object does have `request` attribute. ""This may indicate an update in `hrequests` library.")resp.request=Request(method=method.upper(),url=url,json=json_body,)returnresp