pyaterochka-api 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyaterochka_api/__init__.py +3 -0
- pyaterochka_api/api.py +251 -0
- pyaterochka_api/manager.py +232 -0
- pyaterochka_api-0.1.6.dist-info/METADATA +141 -0
- pyaterochka_api-0.1.6.dist-info/RECORD +12 -0
- pyaterochka_api-0.1.6.dist-info/WHEEL +5 -0
- pyaterochka_api-0.1.6.dist-info/licenses/LICENSE +21 -0
- pyaterochka_api-0.1.6.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/base_tests.py +66 -0
- tests/snapshots/__init__.py +0 -0
- tests/snapshots/snap_base_tests.py +813 -0
pyaterochka_api/api.py
ADDED
@@ -0,0 +1,251 @@
|
|
1
|
+
import aiohttp
|
2
|
+
from fake_useragent import UserAgent
|
3
|
+
from enum import Enum
|
4
|
+
import re
|
5
|
+
from tqdm.asyncio import tqdm
|
6
|
+
from camoufox import AsyncCamoufox
|
7
|
+
|
8
|
+
|
9
|
+
class PyaterochkaAPI:
|
10
|
+
"""
|
11
|
+
Класс для загрузки JSON/image и парсинга JavaScript-конфигураций из удаленного источника.
|
12
|
+
"""
|
13
|
+
|
14
|
+
class Patterns(Enum):
|
15
|
+
JS = r'\s*let\s+n\s*=\s*({.*});\s*' # let n = {...};
|
16
|
+
STR = r'(\w+)\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"' # key: "value"
|
17
|
+
DICT = r'(\w+)\s*:\s*{(.*?)}' # key: {...}
|
18
|
+
LIST = r'(\w+)\s*:\s*\[([^\[\]]*(?:\[.*?\])*)\]' # key: [value]
|
19
|
+
FIND = r'\{.*?\}|\[.*?\]' # {} or []
|
20
|
+
|
21
|
+
def __init__(self, debug: bool = False, proxy: str = None, autoclose_browser: bool = False):
|
22
|
+
self._debug = debug
|
23
|
+
self._proxy = proxy
|
24
|
+
self._session = None
|
25
|
+
self._autoclose_browser = autoclose_browser
|
26
|
+
self._browser = None
|
27
|
+
self._bcontext = None
|
28
|
+
|
29
|
+
@property
|
30
|
+
def proxy(self) -> str | None:
|
31
|
+
return self._proxy if hasattr(self, '_proxy') else None
|
32
|
+
|
33
|
+
@proxy.setter
|
34
|
+
def proxy(self, value: str | None) -> None:
|
35
|
+
self._proxy = value
|
36
|
+
|
37
|
+
async def fetch(self, url: str) -> tuple[bool, dict | None | str, str]:
|
38
|
+
"""
|
39
|
+
Выполняет HTTP-запрос к указанному URL и возвращает результат.
|
40
|
+
|
41
|
+
:return: Кортеж (успех, данные или None).
|
42
|
+
"""
|
43
|
+
if self._debug:
|
44
|
+
print(f"Requesting \"{url}\"...", flush=True)
|
45
|
+
|
46
|
+
async with self._session.get(url=url) as response:
|
47
|
+
if self._debug:
|
48
|
+
print(f"Response status: {response.status}", flush=True)
|
49
|
+
|
50
|
+
if response.status == 200:
|
51
|
+
if response.headers['content-type'] == 'application/json':
|
52
|
+
output_response = response.json()
|
53
|
+
elif response.headers['content-type'] == 'image/jpeg':
|
54
|
+
output_response = response.read()
|
55
|
+
else:
|
56
|
+
output_response = response.text()
|
57
|
+
|
58
|
+
return True, await output_response, response.headers['content-type']
|
59
|
+
elif response.status == 403:
|
60
|
+
if self._debug:
|
61
|
+
print("Anti-bot protection. Use Russia IP address and try again.", flush=True)
|
62
|
+
return False, None, ''
|
63
|
+
else:
|
64
|
+
if self._debug:
|
65
|
+
print(f"Unexpected error: {response.status}", flush=True)
|
66
|
+
raise Exception(f"Response status: {response.status} (unknown error/status code)")
|
67
|
+
|
68
|
+
async def _parse_js(self, js_code: str) -> dict | None:
|
69
|
+
"""
|
70
|
+
Парсит JavaScript-код и извлекает данные из переменной "n".
|
71
|
+
|
72
|
+
:param js_code: JS-код в виде строки.
|
73
|
+
:return: Распарсенные данные в виде словаря или None.
|
74
|
+
"""
|
75
|
+
matches = re.finditer(self.Patterns.JS.value, js_code)
|
76
|
+
match_list = list(matches)
|
77
|
+
|
78
|
+
if self._debug:
|
79
|
+
print(f"Found matches {len(match_list)}")
|
80
|
+
progress_bar = tqdm(total=33, desc="Parsing JS", position=0)
|
81
|
+
|
82
|
+
async def parse_match(match: str) -> dict:
|
83
|
+
result = {}
|
84
|
+
|
85
|
+
if self._debug:
|
86
|
+
progress_bar.set_description("Parsing strings")
|
87
|
+
|
88
|
+
# Парсинг строк
|
89
|
+
string_matches = re.finditer(self.Patterns.STR.value, match)
|
90
|
+
for m in string_matches:
|
91
|
+
key, value = m.group(1), m.group(2)
|
92
|
+
result[key] = value.replace('\"', '"').replace('\\', '\\')
|
93
|
+
|
94
|
+
if self._debug:
|
95
|
+
progress_bar.update(1)
|
96
|
+
progress_bar.set_description("Parsing dictionaries")
|
97
|
+
|
98
|
+
# Парсинг словарей
|
99
|
+
dict_matches = re.finditer(self.Patterns.DICT.value, match)
|
100
|
+
for m in dict_matches:
|
101
|
+
key, value = m.group(1), m.group(2)
|
102
|
+
if not re.search(self.Patterns.STR.value, value):
|
103
|
+
result[key] = await parse_match(value)
|
104
|
+
|
105
|
+
if self._debug:
|
106
|
+
progress_bar.update(1)
|
107
|
+
progress_bar.set_description("Parsing lists")
|
108
|
+
|
109
|
+
# Парсинг списков
|
110
|
+
list_matches = re.finditer(self.Patterns.LIST.value, match)
|
111
|
+
for m in list_matches:
|
112
|
+
key, value = m.group(1), m.group(2)
|
113
|
+
if not re.search(self.Patterns.STR.value, value):
|
114
|
+
result[key] = [await parse_match(item.group(0)) for item in re.finditer(self.Patterns.FIND.value, value)]
|
115
|
+
|
116
|
+
if self._debug:
|
117
|
+
progress_bar.update(1)
|
118
|
+
|
119
|
+
return result
|
120
|
+
|
121
|
+
if match_list and len(match_list) >= 1:
|
122
|
+
if self._debug:
|
123
|
+
print("Starting to parse match")
|
124
|
+
result = await parse_match(match_list[1].group(0))
|
125
|
+
if self._debug:
|
126
|
+
progress_bar.close()
|
127
|
+
return result
|
128
|
+
else:
|
129
|
+
if self._debug:
|
130
|
+
progress_bar.close()
|
131
|
+
raise Exception("N variable in JS code not found")
|
132
|
+
|
133
|
+
async def download_config(self, config_url: str) -> dict | None:
|
134
|
+
"""
|
135
|
+
Загружает и парсит JavaScript-конфигурацию с указанного URL.
|
136
|
+
|
137
|
+
:param config_url: URL для загрузки конфигурации.
|
138
|
+
:return: Распарсенные данные в виде словаря или None.
|
139
|
+
"""
|
140
|
+
is_success, js_code, _response_type = await self.fetch(url=config_url)
|
141
|
+
|
142
|
+
if not is_success:
|
143
|
+
if self._debug:
|
144
|
+
print("Failed to fetch JS code")
|
145
|
+
return None
|
146
|
+
elif self._debug:
|
147
|
+
print("JS code fetched successfully")
|
148
|
+
|
149
|
+
return await self._parse_js(js_code=js_code)
|
150
|
+
|
151
|
+
|
152
|
+
async def _browser_fetch(self, url: str, selector: str, state: str = 'attached') -> dict:
|
153
|
+
if self._browser is None or self._bcontext is None:
|
154
|
+
await self._new_session(include_aiohttp=False, include_browser=True)
|
155
|
+
|
156
|
+
page = await self._bcontext.new_page()
|
157
|
+
await page.goto(url, wait_until='commit')
|
158
|
+
# Wait until the selector script tag appears
|
159
|
+
await page.wait_for_selector(selector=selector, state=state)
|
160
|
+
content = await page.content()
|
161
|
+
await page.close()
|
162
|
+
|
163
|
+
if self._autoclose_browser:
|
164
|
+
await self.close(include_aiohttp=False, include_browser=True)
|
165
|
+
return content
|
166
|
+
|
167
|
+
def _parse_proxy(self, proxy_str: str | None) -> dict | None:
|
168
|
+
if not proxy_str:
|
169
|
+
return None
|
170
|
+
|
171
|
+
# Example: user:pass@host:port or just host:port
|
172
|
+
match = re.match(
|
173
|
+
r'^(?:(?P<scheme>https?:\/\/))?(?:(?P<username>[^:@]+):(?P<password>[^@]+)@)?(?P<host>[^:]+):(?P<port>\d+)$',
|
174
|
+
proxy_str,
|
175
|
+
)
|
176
|
+
|
177
|
+
proxy_dict = {}
|
178
|
+
if not match:
|
179
|
+
proxy_dict['server'] = proxy_str
|
180
|
+
|
181
|
+
if not proxy_str.startswith('http://') and not proxy_str.startswith('https://'):
|
182
|
+
proxy_dict['server'] = f"http://{proxy_str}"
|
183
|
+
|
184
|
+
return proxy_dict
|
185
|
+
else:
|
186
|
+
match_dict = match.groupdict()
|
187
|
+
proxy_dict['server'] = f"{match_dict['scheme'] or 'http://'}{match_dict['host']}:{match_dict['port']}"
|
188
|
+
|
189
|
+
for key in ['username', 'password']:
|
190
|
+
if match_dict[key]:
|
191
|
+
proxy_dict[key] = match_dict[key]
|
192
|
+
|
193
|
+
return proxy_dict
|
194
|
+
|
195
|
+
async def _new_session(self, include_aiohttp: bool = True, include_browser: bool = False) -> None:
|
196
|
+
await self.close(include_aiohttp=include_aiohttp, include_browser=include_browser)
|
197
|
+
|
198
|
+
if include_aiohttp:
|
199
|
+
args = {"headers": {"User-Agent": UserAgent().random}}
|
200
|
+
if self._proxy: args["proxy"] = self._proxy
|
201
|
+
self._session = aiohttp.ClientSession(**args)
|
202
|
+
|
203
|
+
if self._debug: print(f"A new connection aiohttp has been opened. Proxy used: {args.get('proxy')}")
|
204
|
+
|
205
|
+
if include_browser:
|
206
|
+
self._browser = await AsyncCamoufox(headless=not self._debug, proxy=self._parse_proxy(self.proxy), geoip=True).__aenter__()
|
207
|
+
self._bcontext = await self._browser.new_context()
|
208
|
+
|
209
|
+
if self._debug: print(f"A new connection browser has been opened. Proxy used: {self.proxy}")
|
210
|
+
|
211
|
+
async def close(
|
212
|
+
self,
|
213
|
+
include_aiohttp: bool = True,
|
214
|
+
include_browser: bool = False
|
215
|
+
) -> None:
|
216
|
+
"""
|
217
|
+
Close the aiohttp session and/or Camoufox browser if they are open.
|
218
|
+
:param include_aiohttp: close aiohttp session if True
|
219
|
+
:param include_browser: close browser if True
|
220
|
+
"""
|
221
|
+
to_close = []
|
222
|
+
if include_aiohttp:
|
223
|
+
to_close.append("session")
|
224
|
+
if include_browser:
|
225
|
+
to_close.append("bcontext")
|
226
|
+
to_close.append("browser")
|
227
|
+
|
228
|
+
if not to_close:
|
229
|
+
raise ValueError("No connections to close")
|
230
|
+
|
231
|
+
checks = {
|
232
|
+
"session": lambda a: a is not None and not a.closed,
|
233
|
+
"browser": lambda a: a is not None,
|
234
|
+
"bcontext": lambda a: a is not None
|
235
|
+
}
|
236
|
+
|
237
|
+
for name in to_close:
|
238
|
+
attr = getattr(self, f"_{name}", None)
|
239
|
+
if checks[name](attr):
|
240
|
+
if "browser" in name:
|
241
|
+
await attr.__aexit__(None, None, None)
|
242
|
+
else:
|
243
|
+
await attr.close()
|
244
|
+
setattr(self, f"_{name}", None)
|
245
|
+
if self._debug:
|
246
|
+
print(f"The {name} connection was closed")
|
247
|
+
else:
|
248
|
+
if self._debug:
|
249
|
+
print(f"The {name} connection was not open")
|
250
|
+
|
251
|
+
|
@@ -0,0 +1,232 @@
|
|
1
|
+
from .api import PyaterochkaAPI
|
2
|
+
from enum import Enum
|
3
|
+
import re
|
4
|
+
import json
|
5
|
+
from io import BytesIO
|
6
|
+
|
7
|
+
|
8
|
+
class Pyaterochka:
|
9
|
+
BASE_URL = "https://5ka.ru"
|
10
|
+
API_URL = "https://5d.5ka.ru/api"
|
11
|
+
HARDCODE_JS_CONFIG = "https://prod-cdn.5ka.ru/scripts/main.a0c039ea81eb8cf69492.js" # TODO сделать не хардкодным имя файла
|
12
|
+
DEFAULT_STORE_ID = "Y232"
|
13
|
+
|
14
|
+
class PurchaseMode(Enum):
|
15
|
+
STORE = "store"
|
16
|
+
DELIVERY = "delivery"
|
17
|
+
|
18
|
+
def __init__(self, debug: bool = False, proxy: str = None, autoclose_browser: bool = False):
|
19
|
+
self._debug = debug
|
20
|
+
self._proxy = proxy
|
21
|
+
self.api = PyaterochkaAPI(debug=self._debug, proxy=self._proxy, autoclose_browser=autoclose_browser)
|
22
|
+
|
23
|
+
def __enter__(self):
|
24
|
+
raise NotImplementedError("Use `async with Pyaterochka() as ...:`")
|
25
|
+
|
26
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
27
|
+
pass
|
28
|
+
|
29
|
+
async def __aenter__(self):
|
30
|
+
await self.rebuild_connection(session=True)
|
31
|
+
return self
|
32
|
+
|
33
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
34
|
+
await self.close()
|
35
|
+
|
36
|
+
async def rebuild_connection(self, session: bool = True, browser: bool = False) -> None:
|
37
|
+
"""
|
38
|
+
Rebuilds the connection to the Pyaterochka API.
|
39
|
+
Args:
|
40
|
+
session (bool, optional): Whether to create a new session (for all, except product_info). Defaults to True.
|
41
|
+
browser (bool, optional): Whether to create a new browser instance (for product_info). Defaults to False.
|
42
|
+
"""
|
43
|
+
await self.api._new_session(session, browser)
|
44
|
+
|
45
|
+
async def close(self, session: bool = True, browser: bool = True) -> None:
|
46
|
+
"""
|
47
|
+
Closes the connection to the Pyaterochka API.
|
48
|
+
Args:
|
49
|
+
session (bool, optional): Whether to close the session (for all, except product_info). Defaults to True.
|
50
|
+
browser (bool, optional): Whether to close the browser instance (for product_info). Defaults to True.
|
51
|
+
"""
|
52
|
+
await self.api.close(include_aiohttp=session, include_browser=browser)
|
53
|
+
|
54
|
+
@property
|
55
|
+
def debug(self) -> bool:
|
56
|
+
"""If True, it will print debug messages and disable headless in browser."""
|
57
|
+
return self._debug
|
58
|
+
|
59
|
+
@debug.setter
|
60
|
+
def debug(self, value: bool):
|
61
|
+
self._debug = value
|
62
|
+
self.api.debug = value
|
63
|
+
|
64
|
+
@property
|
65
|
+
def proxy(self) -> str:
|
66
|
+
"""Proxy for requests. If None, it will be used without proxy."""
|
67
|
+
return self._proxy
|
68
|
+
|
69
|
+
@proxy.setter
|
70
|
+
def proxy(self, value: str):
|
71
|
+
self._proxy = value
|
72
|
+
self.api.proxy = value
|
73
|
+
|
74
|
+
@property
|
75
|
+
def autoclose_browser(self) -> bool:
|
76
|
+
"""If True, the browser closes after each request, clearing all cookies and caches.
|
77
|
+
If you have more than one request and this function is enabled, the processing speed will be greatly affected! (all caches are recreated every time)"""
|
78
|
+
return self.api._autoclose_browser
|
79
|
+
|
80
|
+
@proxy.setter
|
81
|
+
def autoclose_browser(self, value: bool):
|
82
|
+
self.api._autoclose_browser = value
|
83
|
+
|
84
|
+
|
85
|
+
async def categories_list(
|
86
|
+
self,
|
87
|
+
subcategories: bool = False,
|
88
|
+
include_restrict: bool = True,
|
89
|
+
mode: PurchaseMode = PurchaseMode.STORE,
|
90
|
+
sap_code_store_id: str = DEFAULT_STORE_ID
|
91
|
+
) -> dict | None:
|
92
|
+
f"""
|
93
|
+
Asynchronously retrieves a list of categories from the Pyaterochka API.
|
94
|
+
|
95
|
+
Args:
|
96
|
+
subcategories (bool, optional): Whether to include subcategories in the response. Defaults to False.
|
97
|
+
include_restrict (bool, optional): I DO NOT KNOW WHAT IS IT
|
98
|
+
mode (PurchaseMode, optional): The purchase mode to use. Defaults to PurchaseMode.STORE.
|
99
|
+
sap_code_store_id (str, optional): The store ID (official name in API is "sap_code") to use. Defaults to "{self.DEFAULT_STORE_ID}". This lib not support search ID stores.
|
100
|
+
|
101
|
+
Returns:
|
102
|
+
dict | None: A dictionary representing the categories list if the request is successful, None otherwise.
|
103
|
+
|
104
|
+
Raises:
|
105
|
+
Exception: If the response status is not 200 (OK) or 403 (Forbidden / Anti-bot).
|
106
|
+
"""
|
107
|
+
|
108
|
+
request_url = f"{self.API_URL}/catalog/v2/stores/{sap_code_store_id}/categories?mode={mode.value}&include_restrict={include_restrict}&include_subcategories={1 if subcategories else 0}"
|
109
|
+
_is_success, response, _response_type = await self.api.fetch(url=request_url)
|
110
|
+
return response
|
111
|
+
|
112
|
+
async def products_list(
|
113
|
+
self,
|
114
|
+
category_id: int,
|
115
|
+
mode: PurchaseMode = PurchaseMode.STORE,
|
116
|
+
sap_code_store_id: str = DEFAULT_STORE_ID,
|
117
|
+
limit: int = 30
|
118
|
+
) -> dict | None:
|
119
|
+
f"""
|
120
|
+
Asynchronously retrieves a list of products from the Pyaterochka API for a given category.
|
121
|
+
|
122
|
+
Args:
|
123
|
+
category_id (int): The ID of the category.
|
124
|
+
mode (PurchaseMode, optional): The purchase mode to use. Defaults to PurchaseMode.STORE.
|
125
|
+
sap_code_store_id (str, optional): The store ID (official name in API is "sap_code") to use. Defaults to "{self.DEFAULT_STORE_ID}". This lib not support search ID stores.
|
126
|
+
limit (int, optional): The maximum number of products to retrieve. Defaults to 30. Must be between 1 and 499.
|
127
|
+
|
128
|
+
Returns:
|
129
|
+
dict | None: A dictionary representing the products list if the request is successful, None otherwise.
|
130
|
+
|
131
|
+
Raises:
|
132
|
+
ValueError: If the limit is not between 1 and 499.
|
133
|
+
Exception: If the response status is not 200 (OK) or 403 (Forbidden / Anti-bot).
|
134
|
+
"""
|
135
|
+
|
136
|
+
if limit < 1 or limit >= 500:
|
137
|
+
raise ValueError("Limit must be between 1 and 499")
|
138
|
+
|
139
|
+
request_url = f"{self.API_URL}/catalog/v2/stores/{sap_code_store_id}/categories/{category_id}/products?mode={mode.value}&limit={limit}"
|
140
|
+
_is_success, response, _response_type = await self.api.fetch(url=request_url)
|
141
|
+
return response
|
142
|
+
|
143
|
+
async def product_info(self, plu_id: int) -> dict:
|
144
|
+
"""
|
145
|
+
Asynchronously retrieves product information from the Pyaterochka API for a given PLU ID. Average time processing 2 seconds (first start 6 seconds).
|
146
|
+
Args:
|
147
|
+
plu_id (int): The PLU ID of the product.
|
148
|
+
Returns:
|
149
|
+
dict: A dictionary representing the product information.
|
150
|
+
Raises:
|
151
|
+
ValueError: If the response does not contain the expected JSON data.
|
152
|
+
"""
|
153
|
+
|
154
|
+
url = f"{self.BASE_URL}/product/{plu_id}/"
|
155
|
+
response = await self.api._browser_fetch(url=url, selector='script#__NEXT_DATA__[type="application/json"]')
|
156
|
+
|
157
|
+
match = re.search(
|
158
|
+
r'<script\s+id="__NEXT_DATA__"\s+type="application/json">(.+?)</script>',
|
159
|
+
response,
|
160
|
+
flags=re.DOTALL
|
161
|
+
)
|
162
|
+
if not match:
|
163
|
+
raise ValueError("product_info: Failed to find JSON data in the response")
|
164
|
+
json_text = match.group(1)
|
165
|
+
data = json.loads(json_text)
|
166
|
+
data["props"]["pageProps"]["props"]["productStore"] = json.loads(data["props"]["pageProps"]["props"]["productStore"])
|
167
|
+
data["props"]["pageProps"]["props"]["catalogStore"] = json.loads(data["props"]["pageProps"]["props"]["catalogStore"])
|
168
|
+
data["props"]["pageProps"]["props"]["filtersPageStore"] = json.loads(data["props"]["pageProps"]["props"]["filtersPageStore"])
|
169
|
+
|
170
|
+
return data
|
171
|
+
|
172
|
+
async def get_news(self, limit: int = None) -> dict | None:
|
173
|
+
"""
|
174
|
+
Asynchronously retrieves news from the Pyaterochka API.
|
175
|
+
|
176
|
+
Args:
|
177
|
+
limit (int, optional): The maximum number of news items to retrieve. Defaults to None.
|
178
|
+
|
179
|
+
Returns:
|
180
|
+
dict | None: A dictionary representing the news if the request is successful, None otherwise.
|
181
|
+
"""
|
182
|
+
url = f"{self.BASE_URL}/api/public/v1/news/"
|
183
|
+
if limit and limit > 0:
|
184
|
+
url += f"?limit={limit}"
|
185
|
+
|
186
|
+
_is_success, response, _response_type = await self.api.fetch(url=url)
|
187
|
+
|
188
|
+
return response
|
189
|
+
|
190
|
+
async def find_store(self, longitude: float, latitude: float) -> dict | None:
|
191
|
+
"""
|
192
|
+
Asynchronously finds the store associated with the given coordinates.
|
193
|
+
|
194
|
+
Args:
|
195
|
+
longitude (float): The longitude of the location.
|
196
|
+
latitude (float): The latitude of the location.
|
197
|
+
|
198
|
+
Returns:
|
199
|
+
dict | None: A dictionary representing the store information if the request is successful, None otherwise.
|
200
|
+
"""
|
201
|
+
|
202
|
+
request_url = f"{self.API_URL}/orders/v1/orders/stores/?lon={longitude}&lat={latitude}"
|
203
|
+
_is_success, response, _response_type = await self.api.fetch(url=request_url)
|
204
|
+
return response
|
205
|
+
|
206
|
+
async def download_image(self, url: str) -> BytesIO | None:
|
207
|
+
is_success, image_data, response_type = await self.api.fetch(url=url)
|
208
|
+
|
209
|
+
if not is_success:
|
210
|
+
if self.debug:
|
211
|
+
print("Failed to fetch image")
|
212
|
+
return None
|
213
|
+
elif self.debug:
|
214
|
+
print("Image fetched successfully")
|
215
|
+
|
216
|
+
image = BytesIO(image_data)
|
217
|
+
image.name = f'{url.split("/")[-1]}.{response_type.split("/")[-1]}'
|
218
|
+
|
219
|
+
return image
|
220
|
+
|
221
|
+
async def get_config(self) -> list | None:
|
222
|
+
"""
|
223
|
+
Asynchronously retrieves the configuration from the hardcoded JavaScript file.
|
224
|
+
|
225
|
+
Args:
|
226
|
+
debug (bool, optional): Whether to print debug information. Defaults to False.
|
227
|
+
|
228
|
+
Returns:
|
229
|
+
list | None: A list representing the configuration if the request is successful, None otherwise.
|
230
|
+
"""
|
231
|
+
|
232
|
+
return await self.api.download_config(config_url=self.HARDCODE_JS_CONFIG)
|
@@ -0,0 +1,141 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: pyaterochka_api
|
3
|
+
Version: 0.1.6
|
4
|
+
Summary: A Python API client for Pyaterochka store catalog
|
5
|
+
Home-page: https://github.com/Open-Inflation/pyaterochka_api
|
6
|
+
Author: Miskler
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
15
|
+
Classifier: Intended Audience :: Developers
|
16
|
+
Classifier: Intended Audience :: Information Technology
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
18
|
+
Classifier: Topic :: Internet
|
19
|
+
Classifier: Topic :: Utilities
|
20
|
+
Requires-Python: >=3.10
|
21
|
+
Description-Content-Type: text/markdown
|
22
|
+
License-File: LICENSE
|
23
|
+
Requires-Dist: aiohttp
|
24
|
+
Requires-Dist: camoufox[geoip]
|
25
|
+
Requires-Dist: fake-useragent
|
26
|
+
Requires-Dist: tqdm
|
27
|
+
Provides-Extra: tests
|
28
|
+
Requires-Dist: pytest; extra == "tests"
|
29
|
+
Requires-Dist: pytest-asyncio; extra == "tests"
|
30
|
+
Requires-Dist: snapshottest~=1.0.0a1; extra == "tests"
|
31
|
+
Dynamic: author
|
32
|
+
Dynamic: classifier
|
33
|
+
Dynamic: description
|
34
|
+
Dynamic: description-content-type
|
35
|
+
Dynamic: home-page
|
36
|
+
Dynamic: license-file
|
37
|
+
Dynamic: provides-extra
|
38
|
+
Dynamic: requires-dist
|
39
|
+
Dynamic: requires-python
|
40
|
+
Dynamic: summary
|
41
|
+
|
42
|
+
# Pyaterochka API *(not official / не официальный)*
|
43
|
+
|
44
|
+
Pyaterochka (Пятёрочка) - https://5ka.ru/
|
45
|
+
|
46
|
+

|
47
|
+

|
48
|
+
[](https://pypi.org/project/pyaterochka-api/)
|
49
|
+
[](https://discord.gg/UnJnGHNbBp)
|
50
|
+
[](https://t.me/miskler_dev)
|
51
|
+
|
52
|
+
|
53
|
+
|
54
|
+
## Installation / Установка:
|
55
|
+
1. Install package / Установка пакета:
|
56
|
+
```bash
|
57
|
+
pip install pyaterochka_api
|
58
|
+
```
|
59
|
+
2. ***Debian/Ubuntu Linux***: Install dependencies / Установка зависимостей:
|
60
|
+
```bash
|
61
|
+
sudo apt update && sudo apt install -y libgtk-3-0 libx11-xcb1
|
62
|
+
```
|
63
|
+
3. Install browser / Установка браузера:
|
64
|
+
```bash
|
65
|
+
camoufox fetch
|
66
|
+
```
|
67
|
+
|
68
|
+
### Usage / Использование:
|
69
|
+
```py
|
70
|
+
from pyaterochka_api import Pyaterochka
|
71
|
+
import asyncio
|
72
|
+
|
73
|
+
|
74
|
+
async def main():
|
75
|
+
async with Pyaterochka(proxy="user:password@host:port", debug=False, autoclose_browser=False) as API:
|
76
|
+
# RUS: Вводим геоточку (самого магазина или рядом с ним) и получаем инфу о магазине
|
77
|
+
# ENG: Enter a geolocation (of the store or near it) and get info about the store
|
78
|
+
find_store = await API.find_store(longitude=37.63156, latitude=55.73768)
|
79
|
+
print(f"Store info output: {find_store!s:.100s}...\n")
|
80
|
+
|
81
|
+
# RUS: Выводит список всех категорий на сайте
|
82
|
+
# ENG: Outputs a list of all categories on the site
|
83
|
+
catalog = await API.categories_list(subcategories=True, mode=API.PurchaseMode.DELIVERY)
|
84
|
+
print(f"Categories list output: {catalog!s:.100s}...\n")
|
85
|
+
|
86
|
+
# RUS: Выводит список всех товаров выбранной категории (ограничение 100 элементов, если превышает - запрашивайте через дополнительные страницы)
|
87
|
+
# ENG: Outputs a list of all items in the selected category (limiting to 100 elements, if exceeds - request through additional pages)
|
88
|
+
# Страниц не сущетвует, использовать желаемый лимит (до 499) / Pages do not exist, use the desired limit (up to 499)
|
89
|
+
items = await API.products_list(catalog[0]['id'], limit=5)
|
90
|
+
print(f"Items list output: {items!s:.100s}...\n")
|
91
|
+
|
92
|
+
# RUS: Выводит информацию о товаре (по его plu - id товара).
|
93
|
+
# Функция в первый раз достаточно долгая, порядка 5-9 секунды, последующие запросы около 2 секунд (если браузер не был закрыт)
|
94
|
+
# ENG: Outputs information about the product (by its plu - product id).
|
95
|
+
# The function is quite long the first time, about 5-9 seconds, subsequent requests take about 2 seconds (if the browser was not closed)
|
96
|
+
info = await API.product_info(43347)
|
97
|
+
print(f"Product output: {info["props"]["pageProps"]["props"]['productStore']!s:.100s}...\n")
|
98
|
+
|
99
|
+
# RUS: Влияет исключительно на функцию выше (product_info), если включено, то после отработки запроса браузер закроется и кеши очищаются.
|
100
|
+
# Не рекомендую включать, если вам все же нужно освободить память, лучше использовать API.close(session=False, browser=True)
|
101
|
+
# ENG: Affects only the function above (product_info), if enabled, the browser will close after the request is processed and caches are cleared.
|
102
|
+
# I do not recommend enabling it, if you still need to free up memory, it is better to use API.close(session=False, browser=True)
|
103
|
+
API.autoclose_browser = True
|
104
|
+
|
105
|
+
# RUS: Выводит список последних промо-акций/новостей (можно поставить ограничитель по количеству, опционально)
|
106
|
+
# ENG: Outputs a list of the latest promotions/news (you can set a limit on the number, optionally)
|
107
|
+
news = await API.get_news(limit=5)
|
108
|
+
print(f"News output: {news!s:.100s}...\n")
|
109
|
+
|
110
|
+
# RUS: Выводит основной конфиг сайта (очень долгая функция, рекомендую сохранять в файл и переиспользовать)
|
111
|
+
# ENG: Outputs the main config of the site (large function, recommend to save in a file and re-use it)
|
112
|
+
print(f"Main config: {await API.get_config()!s:.100s}...\n")
|
113
|
+
|
114
|
+
# RUS: Если требуется, можно настроить вывод логов в консоль
|
115
|
+
# ENG: If required, you can configure the output of logs in the console
|
116
|
+
API.debug = True
|
117
|
+
|
118
|
+
# RUS: Скачивает картинку товара (возвращает BytesIO или None)
|
119
|
+
# ENG: Downloads the product image (returns BytesIO or None)
|
120
|
+
image = await API.download_image(url=items['products'][0]['image_links']['normal'][0])
|
121
|
+
with open(image.name, 'wb') as f:
|
122
|
+
f.write(image.getbuffer())
|
123
|
+
|
124
|
+
# RUS: Так же как и debug, в рантайме можно переназначить прокси
|
125
|
+
# ENG: As with debug, you can reassign the proxy in runtime
|
126
|
+
API.proxy = "user:password@host:port"
|
127
|
+
# RUS: Чтобы применить изменения, нужно пересоздать подключение (session - aiohttp отвечающее за все, кроме product_info, за него browser)
|
128
|
+
# ENG: To apply changes, you need rebuild connection (session - aiohttp responsible for everything except product_info, for it browser)
|
129
|
+
await API.rebuild_connection()
|
130
|
+
await API.categories_list()
|
131
|
+
|
132
|
+
|
133
|
+
if __name__ == '__main__':
|
134
|
+
asyncio.run(main())
|
135
|
+
```
|
136
|
+
|
137
|
+
### Report / Обратная связь
|
138
|
+
|
139
|
+
If you have any problems using it /suggestions, do not hesitate to write to the [project's GitHub](https://github.com/Open-Inflation/pyaterochka_api/issues)!
|
140
|
+
|
141
|
+
Если у вас возникнут проблемы в использовании / пожелания, не стесняйтесь писать на [GitHub проекта](https://github.com/Open-Inflation/pyaterochka_api/issues)!
|
@@ -0,0 +1,12 @@
|
|
1
|
+
pyaterochka_api/__init__.py,sha256=s91K-ZPzFZkk93AY_BtXwhpSxZpO1IHi1a3RThd1jtM,60
|
2
|
+
pyaterochka_api/api.py,sha256=DHEbtxniZaFnlYUAit3fGBr9dn7wreawjBa1G6huCXU,9829
|
3
|
+
pyaterochka_api/manager.py,sha256=7jiGFMT344iklGSifbjNWeBCkeG9HNqQ3V-D0Gc__pA,9603
|
4
|
+
pyaterochka_api-0.1.6.dist-info/licenses/LICENSE,sha256=Ee_P5XQUYoJuffzRL24j4GWpqgoWphUOKswpB2f9HcQ,1071
|
5
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
6
|
+
tests/base_tests.py,sha256=l_qweYyxL507nRv-SteZdrU-yHONDZQxJfyOSi8pmzw,2576
|
7
|
+
tests/snapshots/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
8
|
+
tests/snapshots/snap_base_tests.py,sha256=0Vc6Vjm8rLyPVbSRUu2xX8XVOEtU_pns2-oJE-EETyQ,27182
|
9
|
+
pyaterochka_api-0.1.6.dist-info/METADATA,sha256=ksmYPe29fLKlxRIjVXkXZgcV5a4RbK4pkSV0G_T-3nk,8098
|
10
|
+
pyaterochka_api-0.1.6.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
|
11
|
+
pyaterochka_api-0.1.6.dist-info/top_level.txt,sha256=PXTSi8y2C5_Mz20pJJFqOUBnjAIAXP_cc38mthvJ2x4,22
|
12
|
+
pyaterochka_api-0.1.6.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2024 Open Inflation
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
tests/__init__.py
ADDED
File without changes
|