Skip to content

Commit 1219415

Browse files
authored
Merge pull request #10 from Open-Inflation/dev
Dev
2 parents 727dc18 + 67db752 commit 1219415

35 files changed

Lines changed: 74911 additions & 700 deletions

chizhik_api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from .manager import ChizhikAPI
22

33
__all__ = ["ChizhikAPI"]
4-
__version__ = "0.2.4"
4+
__version__ = "0.2.5"

chizhik_api/abstraction.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class DeliveryMode:
2+
"""Я хз что это такое"""
3+
4+
STORE = "store"

chizhik_api/endpoints/advertising.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,5 @@ async def active_inout(self) -> FetchResponse:
2222
"""Получить активные рекламные баннеры."""
2323
return await self._parent._request(
2424
HttpMethod.GET,
25-
f"{self._parent.CATALOG_URL}/catalog/unauthorized/active_inout/",
25+
f"{self._parent.API_URL}/v1/catalog/unauthorized/active_inout/",
2626
)

chizhik_api/endpoints/catalog.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from human_requests import ApiChild, ApiParent, api_child_field, autotest
1010
from human_requests.abstraction import FetchResponse, HttpMethod
1111

12+
from ..abstraction import DeliveryMode
13+
1214
if TYPE_CHECKING:
1315
from ..manager import ChizhikAPI # noqa: F401
1416

@@ -33,11 +35,43 @@ def __init__(self, parent: "ChizhikAPI"):
3335
@autotest
3436
async def tree(self, city_id: Optional[str] = None) -> FetchResponse:
3537
"""Получить дерево категорий."""
36-
url = f"{self._parent.CATALOG_URL}/catalog/unauthorized/categories/"
38+
url = f"{self._parent.API_URL}/v1/catalog/unauthorized/categories/"
3739
if city_id:
3840
url += f"?city_id={city_id}"
3941
return await self._parent._request(HttpMethod.GET, url)
4042

43+
@autotest
44+
async def delivery_tree(
45+
self,
46+
store_id: str,
47+
mode: DeliveryMode = DeliveryMode.STORE,
48+
include_restrict: bool = True,
49+
):
50+
url = f"{self._parent.DELIVERY_API_URL}/catalog/v3/stores/{store_id}/categories?mode={mode}&include_subcategories=1&include_restrict={str(include_restrict).lower()}"
51+
return await self._parent._request(HttpMethod.GET, url)
52+
53+
@autotest
54+
async def delivery_tree_extended(
55+
self,
56+
store_id: str,
57+
category_alias: str,
58+
mode: DeliveryMode = DeliveryMode.STORE,
59+
include_restrict: bool = True,
60+
):
61+
url = f"{self._parent.DELIVERY_API_URL}/catalog/v2/stores/{store_id}/categories/{category_alias}/extended?mode={mode}&include_restrict={str(include_restrict).lower()}"
62+
return await self._parent._request(HttpMethod.GET, url)
63+
64+
@autotest
65+
async def delivery_tree_ancestors(
66+
self,
67+
store_id: str,
68+
category_alias: str,
69+
mode: DeliveryMode = DeliveryMode.STORE,
70+
include_restrict: bool = True,
71+
):
72+
url = f"{self._parent.DELIVERY_API_URL}/catalog/v3/stores/{store_id}/categories/{category_alias}/ancestors?mode={mode}&include_restrict={str(include_restrict).lower()}"
73+
return await self._parent._request(HttpMethod.GET, url)
74+
4175
@autotest
4276
async def products_list(
4377
self,
@@ -47,7 +81,7 @@ async def products_list(
4781
search: Optional[str] = None,
4882
) -> FetchResponse:
4983
"""Получить список продуктов в категории."""
50-
url = f"{self._parent.CATALOG_URL}/catalog/unauthorized/products/?page={page}"
84+
url = f"{self._parent.API_URL}/v1/catalog/unauthorized/products/?page={page}"
5185
if category_id:
5286
url += f"&category_id={category_id}"
5387
if city_id:
@@ -56,6 +90,31 @@ async def products_list(
5690
url += f"&term={urllib.parse.quote(search)}"
5791
return await self._parent._request(HttpMethod.GET, url)
5892

93+
@autotest
94+
async def delivery_products_list(
95+
self,
96+
store_id: str,
97+
category_alias: str,
98+
offset: int = 0,
99+
limit: int = 499,
100+
mode: DeliveryMode = DeliveryMode.STORE,
101+
include_restrict: bool = True,
102+
):
103+
url = f"{self._parent.DELIVERY_API_URL}/catalog/v2/stores/{store_id}/categories/{category_alias}/products?mode={mode}&include_restrict={str(include_restrict).lower()}&limit={limit}&offset={offset}"
104+
return await self._parent._request(HttpMethod.GET, url)
105+
106+
@autotest
107+
async def delivery_search(
108+
self,
109+
store_id: str,
110+
query: str,
111+
limit: int = 12,
112+
mode: DeliveryMode = DeliveryMode.STORE,
113+
include_restrict: bool = True,
114+
):
115+
url = f"{self._parent.DELIVERY_API_URL}/catalog/v3/stores/{store_id}/search?mode={mode}&include_restrict={str(include_restrict).lower()}&q={query}&limit={limit}"
116+
return await self._parent._request(HttpMethod.GET, url)
117+
59118

60119
class ProductService(ApiChild["ChizhikAPI"]):
61120
"""Сервис для работы с товарами в каталоге."""
@@ -74,7 +133,19 @@ async def info(
74133
Response: Ответ от сервера с информацией о товаре.
75134
"""
76135

77-
url = f"{self._parent.CATALOG_URL}/catalog/unauthorized/products/{product_id}/"
136+
url = f"{self._parent.API_URL}/v1/catalog/unauthorized/products/{product_id}/"
78137
if city_id:
79138
url += f"?city_id={city_id}"
80139
return await self._parent._request(HttpMethod.GET, url)
140+
141+
@autotest
142+
async def delivery_info(
143+
self,
144+
store_id: str,
145+
product_id: int,
146+
mode: DeliveryMode = DeliveryMode.STORE,
147+
include_restrict: bool = True,
148+
):
149+
# TODO
150+
url = f"{self._parent.DELIVERY_API_URL}/catalog/v2/stores/{store_id}/products/{product_id}?mode={mode}&include_restrict={str(include_restrict).lower()}"
151+
return await self._parent._request(HttpMethod.GET, url)

chizhik_api/endpoints/general.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ async def download_image(
2828
attempts=retry_attempts, start_timeout=3.0, max_timeout=timeout
2929
)
3030

31-
px = self._parent.proxy if isinstance(self._parent.proxy, Proxy) else Proxy(self._parent.proxy)
31+
px = (
32+
self._parent.proxy
33+
if isinstance(self._parent.proxy, Proxy)
34+
else Proxy(self._parent.proxy)
35+
)
3236
async with RetryClient(retry_options=retry_options) as retry_client:
3337
async with retry_client.get(
3438
url,

chizhik_api/endpoints/geolocation.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,51 @@
22

33
from __future__ import annotations
44

5+
from dataclasses import dataclass
56
from typing import TYPE_CHECKING
67

7-
from human_requests import ApiChild, autotest
8+
from human_requests import ApiChild, ApiParent, api_child_field, autotest
89
from human_requests.abstraction import FetchResponse, HttpMethod
910

1011
if TYPE_CHECKING:
1112
from ..manager import ChizhikAPI # noqa: F401
1213

1314

15+
@dataclass(init=False)
1416
class ClassGeolocation(ApiChild["ChizhikAPI"]):
1517
"""Методы для работы с геолокацией и выбором магазинов.
1618
1719
Включает получение информации о городах, адресах, поиск магазинов
1820
и управление настройками доставки.
1921
"""
2022

23+
Shop: ShopService = api_child_field(lambda parent: ShopService(parent.parent))
24+
"""Сервис для работы с информацией о магазинах."""
25+
26+
def __init__(self, parent: "ChizhikAPI"):
27+
super().__init__(parent)
28+
ApiParent.__post_init__(self)
29+
2130
@autotest
2231
async def cities_list(self, search_name: str, page: int = 1) -> FetchResponse:
2332
"""Получить список городов по частичному совпадению имени."""
2433
return await self._parent._request(
2534
HttpMethod.GET,
26-
f"{self._parent.CATALOG_URL}/geo/cities/?name={search_name}&page={page}",
35+
f"{self._parent.API_URL}/v1/geo/cities/?name={search_name}&page={page}",
2736
)
37+
38+
39+
class ShopService(ApiChild["ChizhikAPI"]):
40+
"""Сервис для работы с информацией о магазинах."""
41+
42+
@autotest
43+
async def all(self) -> FetchResponse:
44+
"""Получить список всех точек магазинов."""
45+
url = f"{self._parent.API_URL}/v1/shops"
46+
return await self._parent._request(HttpMethod.GET, url)
47+
48+
@autotest
49+
async def search(self, query: str) -> FetchResponse:
50+
"""Получить список всех точек магазинов."""
51+
url = f"{self._parent.API_URL}/v1/shops?term={query}"
52+
return await self._parent._request(HttpMethod.GET, url)

chizhik_api/manager.py

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

3-
import os
3+
import asyncio
4+
from collections import defaultdict
45
from dataclasses import dataclass, field
56
from typing import Any
67

@@ -13,7 +14,7 @@
1314
api_child_field,
1415
)
1516
from human_requests.abstraction import FetchResponse, HttpMethod, Proxy
16-
from playwright.async_api import TimeoutError as PWTimeoutError
17+
from human_requests.network_analyzer.anomaly_sniffer import HeaderAnomalySniffer
1718

1819
from .endpoints.advertising import ClassAdvertising
1920
from .endpoints.catalog import ClassCatalog
@@ -31,12 +32,15 @@ class ChizhikAPI(ApiParent):
3132
"""Время ожидания ответа от сервера в миллисекундах."""
3233
headless: bool = True
3334
"""Запускать браузер в headless режиме?"""
35+
test_mode: bool = False
36+
"""Режим тестирования предполагает более глубокий _warmup который не требуется для обычного использования"""
3437
proxy: str | dict | Proxy | None = field(default_factory=Proxy.from_env)
3538
"""Прокси-сервер для всех запросов (если нужен). По умолчанию берет из окружения (если есть).
3639
Принимает как формат Playwright, так и строчный формат."""
3740
browser_opts: dict[str, Any] = field(default_factory=dict)
3841
"""Дополнительные опции для браузера (см. https://camoufox.com/python/installation/)"""
39-
CATALOG_URL: str = "https://app.chizhik.club/api/v1"
42+
API_URL: str = "https://app.chizhik.club/api"
43+
DELIVERY_API_URL: str = "https://app.chizhik.club/delivery/api"
4044
"""URL для работы с каталогом."""
4145
MAIN_SITE_URL: str = "https://chizhik.club/catalog/"
4246
"""URL главной страницы сайта."""
@@ -51,6 +55,11 @@ class ChizhikAPI(ApiParent):
5155
page: HumanPage = field(init=False, repr=False)
5256
"""Внутренний страница сессии браузера"""
5357

58+
unstandard_headers: dict[str, str] = field(init=False, repr=False)
59+
"""Список нестандартных заголовков пойманных при инициализации"""
60+
unstandard_urls: dict[str, list[str]] = field(init=False, repr=False)
61+
"""Список нестандартных заголовков пойманных при инициализации"""
62+
5463
Geolocation: ClassGeolocation = api_child_field(ClassGeolocation)
5564
"""API для работы с геолокацией."""
5665
Catalog: ClassCatalog = api_child_field(ClassCatalog)
@@ -74,30 +83,63 @@ async def _warmup(self) -> None:
7483
proxy=px.as_dict(),
7584
**self.browser_opts,
7685
block_images=True,
86+
i_know_what_im_doing=True,
7787
).start()
7888

7989
self.session = HumanBrowser.replace(br)
8090
self.ctx = await self.session.new_context()
8191
self.page = await self.ctx.new_page()
8292
self.page.on_error_screenshot_path = "screenshot.png"
83-
await self.page.goto(self.CATALOG_URL, wait_until="networkidle")
84-
85-
ok = False
86-
try_count = 3
87-
while not ok and try_count > 0:
88-
try_count -= 1
89-
try:
90-
await self.page.wait_for_selector(
91-
"pre", timeout=self.timeout_ms, state="attached"
92-
)
93-
ok = True
94-
except PWTimeoutError:
95-
await self.page.reload()
96-
if not ok:
97-
raise RuntimeError(await self.page.content())
98-
99-
# await self.page.wait_for_load_state("networkidle")
100-
# await asyncio.sleep(3)
93+
94+
if self.test_mode:
95+
sniffer = HeaderAnomalySniffer(
96+
include_subresources=True, # или False, если интересны только документы
97+
url_filter=lambda u: u.startswith(self.API_URL),
98+
)
99+
await sniffer.start(self.ctx)
100+
101+
collected = {}
102+
103+
def on_request(request):
104+
if request.url.startswith(self.API_URL):
105+
collected[request.url] = request.headers
106+
107+
self.ctx.on("request", on_request)
108+
109+
await self.page.goto(self.MAIN_SITE_URL, wait_until="networkidle")
110+
await self.page.wait_for_selector("next-route-announcer", state="attached")
111+
await asyncio.sleep(1)
112+
await self.page.locator(
113+
'main a[data-qa^="sidebar-sub-category-"][data-qa$="-link"]'
114+
).first.click()
115+
await self.page.locator(
116+
'main div[itemtype="https://schema.org/Product"]'
117+
).first.click()
118+
await asyncio.sleep(1)
119+
await self.page.wait_for_load_state("load")
120+
121+
await self.ctx.unroute("**/api/**", on_request)
122+
result_sniffer = await sniffer.complete()
123+
124+
# Результат: {заголовок: [уникальные значения]}
125+
result = defaultdict(set)
126+
127+
# Проходим по всем URL в 'request'
128+
for _url, headers in result_sniffer["request"].items():
129+
for header, values in headers.items():
130+
result[header].update(
131+
values
132+
) # добавляем значения, set уберёт дубли
133+
134+
# Преобразуем set обратно в list
135+
self.unstandard_headers = {k: list(v)[0] for k, v in result.items()}
136+
self.unstandard_urls = collected
137+
138+
await self.page.goto(f"{self.API_URL}/v1", wait_until="networkidle")
139+
140+
await self.page.wait_for_selector(
141+
"pre", timeout=self.timeout_ms, state="attached"
142+
)
101143

102144
async def __aexit__(self, *exc):
103145
"""Выход из контекстного менеджера с закрытием сессии."""

0 commit comments

Comments
 (0)