pyaterochka-api 0.1.6__py3-none-any.whl → 0.1.8__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.
@@ -1,3 +1,4 @@
1
1
  from .manager import Pyaterochka
2
+ from .enums import PurchaseMode
2
3
 
3
- __all__ = ['Pyaterochka']
4
+ __all__ = ['Pyaterochka', 'PurchaseMode']
pyaterochka_api/api.py CHANGED
@@ -1,9 +1,8 @@
1
1
  import aiohttp
2
2
  from fake_useragent import UserAgent
3
- from enum import Enum
4
- import re
5
- from tqdm.asyncio import tqdm
6
3
  from camoufox import AsyncCamoufox
4
+ import logging
5
+ from .tools import parse_proxy, parse_js, get_env_proxy
7
6
 
8
7
 
9
8
  class PyaterochkaAPI:
@@ -11,41 +10,42 @@ class PyaterochkaAPI:
11
10
  Класс для загрузки JSON/image и парсинга JavaScript-конфигураций из удаленного источника.
12
11
  """
13
12
 
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):
13
+ def __init__(self,
14
+ debug: bool = False,
15
+ proxy: str | None = None,
16
+ autoclose_browser: bool = False,
17
+ trust_env: bool = False,
18
+ timeout: float = 10.0
19
+ ):
22
20
  self._debug = debug
23
21
  self._proxy = proxy
24
22
  self._session = None
25
23
  self._autoclose_browser = autoclose_browser
26
24
  self._browser = None
27
25
  self._bcontext = None
26
+ self._trust_env = trust_env
27
+ self._timeout = timeout
28
28
 
29
- @property
30
- def proxy(self) -> str | None:
31
- return self._proxy if hasattr(self, '_proxy') else None
29
+ self._logger = logging.getLogger(self.__class__.__name__)
30
+ handler = logging.StreamHandler()
31
+ formatter = logging.Formatter('[%(asctime)s] %(levelname)s %(name)s: %(message)s')
32
+ handler.setFormatter(formatter)
33
+ if not self._logger.hasHandlers():
34
+ self._logger.addHandler(handler)
32
35
 
33
- @proxy.setter
34
- def proxy(self, value: str | None) -> None:
35
- self._proxy = value
36
-
37
36
  async def fetch(self, url: str) -> tuple[bool, dict | None | str, str]:
38
37
  """
39
38
  Выполняет HTTP-запрос к указанному URL и возвращает результат.
40
39
 
41
- :return: Кортеж (успех, данные или None).
40
+ :return: Кортеж (успех, данные или None, тип данных или пустота).
42
41
  """
43
- if self._debug:
44
- print(f"Requesting \"{url}\"...", flush=True)
42
+ args = {'url': url, 'timeout': aiohttp.ClientTimeout(total=self._timeout)}
43
+ if self._proxy: args["proxy"] = self._proxy
45
44
 
46
- async with self._session.get(url=url) as response:
47
- if self._debug:
48
- print(f"Response status: {response.status}", flush=True)
45
+ self._logger.info(f'Requesting "{url}" with proxy: "{args.get("proxy") or ("SYSTEM_PROXY" if get_env_proxy() else "WITHOUT")}", timeout: {self._timeout}...')
46
+
47
+ async with self._session.get(**args) as response:
48
+ self._logger.info(f'Response status: {response.status}')
49
49
 
50
50
  if response.status == 200:
51
51
  if response.headers['content-type'] == 'application/json':
@@ -57,79 +57,12 @@ class PyaterochkaAPI:
57
57
 
58
58
  return True, await output_response, response.headers['content-type']
59
59
  elif response.status == 403:
60
- if self._debug:
61
- print("Anti-bot protection. Use Russia IP address and try again.", flush=True)
60
+ self._logger.warning('Anti-bot protection. Use Russia IP address and try again.')
62
61
  return False, None, ''
63
62
  else:
64
- if self._debug:
65
- print(f"Unexpected error: {response.status}", flush=True)
63
+ self._logger.error(f'Unexpected error: {response.status}')
66
64
  raise Exception(f"Response status: {response.status} (unknown error/status code)")
67
65
 
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
66
  async def download_config(self, config_url: str) -> dict | None:
134
67
  """
135
68
  Загружает и парсит JavaScript-конфигурацию с указанного URL.
@@ -141,22 +74,22 @@ class PyaterochkaAPI:
141
74
 
142
75
  if not is_success:
143
76
  if self._debug:
144
- print("Failed to fetch JS code")
77
+ self._logger.error('Failed to fetch JS code')
145
78
  return None
146
79
  elif self._debug:
147
- print("JS code fetched successfully")
80
+ self._logger.debug('JS code fetched successfully')
148
81
 
149
- return await self._parse_js(js_code=js_code)
82
+ return await parse_js(js_code=js_code, debug=self._debug, logger=self._logger)
150
83
 
151
84
 
152
- async def _browser_fetch(self, url: str, selector: str, state: str = 'attached') -> dict:
85
+ async def browser_fetch(self, url: str, selector: str, state: str = 'attached') -> dict:
153
86
  if self._browser is None or self._bcontext is None:
154
- await self._new_session(include_aiohttp=False, include_browser=True)
87
+ await self.new_session(include_aiohttp=False, include_browser=True)
155
88
 
156
89
  page = await self._bcontext.new_page()
157
- await page.goto(url, wait_until='commit')
90
+ await page.goto(url, wait_until='commit', timeout=self._timeout * 1000)
158
91
  # Wait until the selector script tag appears
159
- await page.wait_for_selector(selector=selector, state=state)
92
+ await page.wait_for_selector(selector=selector, state=state, timeout=self._timeout * 1000)
160
93
  content = await page.content()
161
94
  await page.close()
162
95
 
@@ -164,49 +97,37 @@ class PyaterochkaAPI:
164
97
  await self.close(include_aiohttp=False, include_browser=True)
165
98
  return content
166
99
 
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:
100
+ async def new_session(self, include_aiohttp: bool = True, include_browser: bool = False) -> None:
196
101
  await self.close(include_aiohttp=include_aiohttp, include_browser=include_browser)
197
102
 
198
103
  if include_aiohttp:
199
- args = {"headers": {"User-Agent": UserAgent().random}}
200
- if self._proxy: args["proxy"] = self._proxy
104
+ args = {
105
+ "headers": {
106
+ "User-Agent": UserAgent().random,
107
+ "Accept": "application/json, text/plain, */*",
108
+ "Accept-Language": "en-GB,en;q=0.5",
109
+ "Accept-Encoding": "gzip, deflate, br, zstd",
110
+ "X-PLATFORM": "webapp",
111
+ "Origin": "https://5ka.ru",
112
+ "Connection": "keep-alive",
113
+ "Sec-Fetch-Dest": "empty",
114
+ "Sec-Fetch-Mode": "cors",
115
+ "Sec-Fetch-Site": "same-site",
116
+ "Pragma": "no-cache",
117
+ "Cache-Control": "no-cache",
118
+ "TE": "trailers",
119
+ },
120
+ "trust_env": self._trust_env,
121
+ }
201
122
  self._session = aiohttp.ClientSession(**args)
202
-
203
- if self._debug: print(f"A new connection aiohttp has been opened. Proxy used: {args.get('proxy')}")
123
+ self._logger.info(f"A new aiohttp connection has been opened. trust_env: {args.get('trust_env')}")
204
124
 
205
125
  if include_browser:
206
- self._browser = await AsyncCamoufox(headless=not self._debug, proxy=self._parse_proxy(self.proxy), geoip=True).__aenter__()
126
+ prox = parse_proxy(self._proxy, self._trust_env, self._logger)
127
+ self._logger.info(f"Opening new browser connection with proxy: {'SYSTEM_PROXY' if prox and not self._proxy else prox}")
128
+ self._browser = await AsyncCamoufox(headless=not self._debug, proxy=prox, geoip=True).__aenter__()
207
129
  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}")
130
+ self._logger.info(f"A new browser context has been opened.")
210
131
 
211
132
  async def close(
212
133
  self,
@@ -225,8 +146,11 @@ class PyaterochkaAPI:
225
146
  to_close.append("bcontext")
226
147
  to_close.append("browser")
227
148
 
149
+ self._logger.info(f"Preparing to close: {to_close if to_close else 'nothing'}")
150
+
228
151
  if not to_close:
229
- raise ValueError("No connections to close")
152
+ self._logger.warning("No connections to close")
153
+ return
230
154
 
231
155
  checks = {
232
156
  "session": lambda a: a is not None and not a.closed,
@@ -237,15 +161,20 @@ class PyaterochkaAPI:
237
161
  for name in to_close:
238
162
  attr = getattr(self, f"_{name}", None)
239
163
  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")
164
+ self._logger.info(f"Closing {name} connection...")
165
+ try:
166
+ if name == "browser":
167
+ await attr.__aexit__(None, None, None)
168
+ elif name in ["bcontext", "session"]:
169
+ await attr.close()
170
+ else:
171
+ raise ValueError(f"Unknown connection type: {name}")
172
+
173
+ setattr(self, f"_{name}", None)
174
+ self._logger.info(f"The {name} connection was closed")
175
+ except Exception as e:
176
+ self._logger.error(f"Error closing {name}: {e}")
247
177
  else:
248
- if self._debug:
249
- print(f"The {name} connection was not open")
178
+ self._logger.warning(f"The {name} connection was not open")
250
179
 
251
180
 
@@ -0,0 +1,14 @@
1
+ from enum import Enum
2
+
3
+ class Patterns(Enum):
4
+ JS = r'\s*let\s+n\s*=\s*({.*});\s*' # let n = {...};
5
+ STR = r'(\w+)\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"' # key: "value"
6
+ DICT = r'(\w+)\s*:\s*{(.*?)}' # key: {...}
7
+ LIST = r'(\w+)\s*:\s*\[([^\[\]]*(?:\[.*?\])*)\]' # key: [value]
8
+ FIND = r'\{.*?\}|\[.*?\]' # {} or []
9
+ # http(s)://user:pass@host:port
10
+ PROXY = r'^(?:(?P<scheme>https?:\/\/))?(?:(?P<username>[^:@]+):(?P<password>[^@]+)@)?(?P<host>[^:\/]+)(?::(?P<port>\d+))?$'
11
+
12
+ class PurchaseMode(Enum):
13
+ STORE = "store"
14
+ DELIVERY = "delivery"
@@ -3,36 +3,44 @@ from enum import Enum
3
3
  import re
4
4
  import json
5
5
  from io import BytesIO
6
+ from beartype import beartype
7
+ from .enums import PurchaseMode
6
8
 
7
9
 
8
10
  class Pyaterochka:
9
- BASE_URL = "https://5ka.ru"
10
- API_URL = "https://5d.5ka.ru/api"
11
+ BASE_URL = "https://5ka.ru"
12
+ API_URL = "https://5d.5ka.ru/api"
11
13
  HARDCODE_JS_CONFIG = "https://prod-cdn.5ka.ru/scripts/main.a0c039ea81eb8cf69492.js" # TODO сделать не хардкодным имя файла
12
- DEFAULT_STORE_ID = "Y232"
14
+ DEFAULT_STORE_ID = "Y232"
13
15
 
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)
16
+ @beartype
17
+ def __init__(self, debug: bool = False, proxy: str | None = None, autoclose_browser: bool = False, trust_env: bool = False, timeout: float = 10.0):
18
+ self.api = PyaterochkaAPI()
19
+
20
+ self.debug = debug
21
+ self.proxy = proxy
22
+ self.autoclose_browser = autoclose_browser
23
+ self.trust_env = trust_env
24
+ self.timeout = timeout
22
25
 
26
+ @beartype
23
27
  def __enter__(self):
24
28
  raise NotImplementedError("Use `async with Pyaterochka() as ...:`")
25
29
 
30
+ @beartype
26
31
  def __exit__(self, exc_type, exc_val, exc_tb):
27
32
  pass
28
33
 
34
+ @beartype
29
35
  async def __aenter__(self):
30
- await self.rebuild_connection(session=True)
36
+ await self.rebuild_connection(session=True, browser=False)
31
37
  return self
32
38
 
39
+ @beartype
33
40
  async def __aexit__(self, exc_type, exc_val, exc_tb):
34
41
  await self.close()
35
42
 
43
+ @beartype
36
44
  async def rebuild_connection(self, session: bool = True, browser: bool = False) -> None:
37
45
  """
38
46
  Rebuilds the connection to the Pyaterochka API.
@@ -40,8 +48,9 @@ class Pyaterochka:
40
48
  session (bool, optional): Whether to create a new session (for all, except product_info). Defaults to True.
41
49
  browser (bool, optional): Whether to create a new browser instance (for product_info). Defaults to False.
42
50
  """
43
- await self.api._new_session(session, browser)
51
+ await self.api.new_session(session, browser)
44
52
 
53
+ @beartype
45
54
  async def close(self, session: bool = True, browser: bool = True) -> None:
46
55
  """
47
56
  Closes the connection to the Pyaterochka API.
@@ -52,43 +61,72 @@ class Pyaterochka:
52
61
  await self.api.close(include_aiohttp=session, include_browser=browser)
53
62
 
54
63
  @property
64
+ @beartype
55
65
  def debug(self) -> bool:
56
66
  """If True, it will print debug messages and disable headless in browser."""
57
- return self._debug
67
+ return self.api._debug
58
68
 
59
69
  @debug.setter
70
+ @beartype
60
71
  def debug(self, value: bool):
61
- self._debug = value
62
- self.api.debug = value
72
+ self.api._debug = value
63
73
 
64
74
  @property
65
- def proxy(self) -> str:
75
+ @beartype
76
+ def proxy(self) -> str | None:
66
77
  """Proxy for requests. If None, it will be used without proxy."""
67
- return self._proxy
78
+ return self.api._proxy
68
79
 
69
80
  @proxy.setter
70
- def proxy(self, value: str):
71
- self._proxy = value
72
- self.api.proxy = value
81
+ @beartype
82
+ def proxy(self, value: str | None):
83
+ self.api._proxy = value
73
84
 
74
85
  @property
86
+ @beartype
75
87
  def autoclose_browser(self) -> bool:
76
88
  """If True, the browser closes after each request, clearing all cookies and caches.
77
89
  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
90
  return self.api._autoclose_browser
79
91
 
80
- @proxy.setter
92
+ @autoclose_browser.setter
93
+ @beartype
81
94
  def autoclose_browser(self, value: bool):
82
95
  self.api._autoclose_browser = value
96
+
97
+ @property
98
+ @beartype
99
+ def trust_env(self) -> bool:
100
+ """Passed directly to aiohttp. Also, if no proxy is specified, the system proxy (variable HTTPS_PROXY) will be used for the browser."""
101
+ return self.api._trust_env
102
+
103
+ @trust_env.setter
104
+ @beartype
105
+ def trust_env(self, value: bool):
106
+ self.api._trust_env = value
107
+
108
+ @property
109
+ @beartype
110
+ def timeout(self) -> float:
111
+ """Timeout value for the API requests."""
112
+ return self.api._timeout
113
+
114
+ @timeout.setter
115
+ @beartype
116
+ def timeout(self, value: float):
117
+ if value <= 0:
118
+ raise ValueError("Timeout must be greater than 0")
83
119
 
120
+ self.api._timeout = value
84
121
 
122
+ @beartype
85
123
  async def categories_list(
86
124
  self,
87
125
  subcategories: bool = False,
88
126
  include_restrict: bool = True,
89
127
  mode: PurchaseMode = PurchaseMode.STORE,
90
128
  sap_code_store_id: str = DEFAULT_STORE_ID
91
- ) -> dict | None:
129
+ ) -> list[dict]:
92
130
  f"""
93
131
  Asynchronously retrieves a list of categories from the Pyaterochka API.
94
132
 
@@ -99,7 +137,7 @@ class Pyaterochka:
99
137
  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
138
 
101
139
  Returns:
102
- dict | None: A dictionary representing the categories list if the request is successful, None otherwise.
140
+ list[dict]: A dictionary representing the categories list if the request is successful, error otherwise.
103
141
 
104
142
  Raises:
105
143
  Exception: If the response status is not 200 (OK) or 403 (Forbidden / Anti-bot).
@@ -109,24 +147,25 @@ class Pyaterochka:
109
147
  _is_success, response, _response_type = await self.api.fetch(url=request_url)
110
148
  return response
111
149
 
150
+ @beartype
112
151
  async def products_list(
113
152
  self,
114
- category_id: int,
153
+ category_id: str,
115
154
  mode: PurchaseMode = PurchaseMode.STORE,
116
155
  sap_code_store_id: str = DEFAULT_STORE_ID,
117
156
  limit: int = 30
118
- ) -> dict | None:
157
+ ) -> dict:
119
158
  f"""
120
159
  Asynchronously retrieves a list of products from the Pyaterochka API for a given category.
121
160
 
122
161
  Args:
123
- category_id (int): The ID of the category.
162
+ category_id (str): The ID of the (sub)category.
124
163
  mode (PurchaseMode, optional): The purchase mode to use. Defaults to PurchaseMode.STORE.
125
164
  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
165
  limit (int, optional): The maximum number of products to retrieve. Defaults to 30. Must be between 1 and 499.
127
166
 
128
167
  Returns:
129
- dict | None: A dictionary representing the products list if the request is successful, None otherwise.
168
+ dict: A dictionary representing the products list if the request is successful, error otherwise.
130
169
 
131
170
  Raises:
132
171
  ValueError: If the limit is not between 1 and 499.
@@ -140,9 +179,11 @@ class Pyaterochka:
140
179
  _is_success, response, _response_type = await self.api.fetch(url=request_url)
141
180
  return response
142
181
 
182
+ @beartype
143
183
  async def product_info(self, plu_id: int) -> dict:
144
184
  """
145
185
  Asynchronously retrieves product information from the Pyaterochka API for a given PLU ID. Average time processing 2 seconds (first start 6 seconds).
186
+
146
187
  Args:
147
188
  plu_id (int): The PLU ID of the product.
148
189
  Returns:
@@ -152,7 +193,7 @@ class Pyaterochka:
152
193
  """
153
194
 
154
195
  url = f"{self.BASE_URL}/product/{plu_id}/"
155
- response = await self.api._browser_fetch(url=url, selector='script#__NEXT_DATA__[type="application/json"]')
196
+ response = await self.api.browser_fetch(url=url, selector='script#__NEXT_DATA__[type="application/json"]')
156
197
 
157
198
  match = re.search(
158
199
  r'<script\s+id="__NEXT_DATA__"\s+type="application/json">(.+?)</script>',
@@ -169,7 +210,8 @@ class Pyaterochka:
169
210
 
170
211
  return data
171
212
 
172
- async def get_news(self, limit: int = None) -> dict | None:
213
+ @beartype
214
+ async def get_news(self, limit: int | None = None) -> dict:
173
215
  """
174
216
  Asynchronously retrieves news from the Pyaterochka API.
175
217
 
@@ -177,7 +219,7 @@ class Pyaterochka:
177
219
  limit (int, optional): The maximum number of news items to retrieve. Defaults to None.
178
220
 
179
221
  Returns:
180
- dict | None: A dictionary representing the news if the request is successful, None otherwise.
222
+ dict: A dictionary representing the news if the request is successful, error otherwise.
181
223
  """
182
224
  url = f"{self.BASE_URL}/api/public/v1/news/"
183
225
  if limit and limit > 0:
@@ -187,7 +229,8 @@ class Pyaterochka:
187
229
 
188
230
  return response
189
231
 
190
- async def find_store(self, longitude: float, latitude: float) -> dict | None:
232
+ @beartype
233
+ async def find_store(self, longitude: float, latitude: float) -> dict:
191
234
  """
192
235
  Asynchronously finds the store associated with the given coordinates.
193
236
 
@@ -196,29 +239,33 @@ class Pyaterochka:
196
239
  latitude (float): The latitude of the location.
197
240
 
198
241
  Returns:
199
- dict | None: A dictionary representing the store information if the request is successful, None otherwise.
242
+ dict: A dictionary representing the store information if the request is successful, error otherwise.
200
243
  """
201
244
 
202
245
  request_url = f"{self.API_URL}/orders/v1/orders/stores/?lon={longitude}&lat={latitude}"
203
246
  _is_success, response, _response_type = await self.api.fetch(url=request_url)
204
247
  return response
205
248
 
206
- async def download_image(self, url: str) -> BytesIO | None:
249
+ @beartype
250
+ async def download_image(self, url: str) -> BytesIO:
207
251
  is_success, image_data, response_type = await self.api.fetch(url=url)
208
252
 
209
253
  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")
254
+ self.api._logger.error("Failed to fetch image")
255
+ return
256
+ elif not isinstance(image_data, (bytes, bytearray)):
257
+ self.api._logger.error("Image data is not bytes")
258
+ return
259
+
260
+ self.api._logger.debug("Image fetched successfully")
215
261
 
216
262
  image = BytesIO(image_data)
217
263
  image.name = f'{url.split("/")[-1]}.{response_type.split("/")[-1]}'
218
264
 
219
265
  return image
220
266
 
221
- async def get_config(self) -> list | None:
267
+ @beartype
268
+ async def get_config(self) -> dict:
222
269
  """
223
270
  Asynchronously retrieves the configuration from the hardcoded JavaScript file.
224
271
 
@@ -226,7 +273,7 @@ class Pyaterochka:
226
273
  debug (bool, optional): Whether to print debug information. Defaults to False.
227
274
 
228
275
  Returns:
229
- list | None: A list representing the configuration if the request is successful, None otherwise.
276
+ dict: A dictionary representing the configuration if the request is successful, error otherwise.
230
277
  """
231
278
 
232
279
  return await self.api.download_config(config_url=self.HARDCODE_JS_CONFIG)
@@ -0,0 +1,121 @@
1
+ from .enums import Patterns
2
+ import os
3
+ import re
4
+ from tqdm import tqdm
5
+
6
+ def get_env_proxy() -> str | None:
7
+ """
8
+ Получает прокси из переменных окружения.
9
+ :return: Прокси-строка или None.
10
+ """
11
+ proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy")
12
+ return proxy if proxy else None
13
+
14
+ def parse_proxy(proxy_str: str | None, trust_env: bool, logger) -> dict | None:
15
+ logger.debug(f"Parsing proxy string: {proxy_str}")
16
+
17
+ if not proxy_str:
18
+ if trust_env:
19
+ logger.debug("Proxy string not provided, checking environment variables for HTTP(S)_PROXY")
20
+ proxy_str = get_env_proxy()
21
+
22
+ if not proxy_str:
23
+ logger.info("No proxy string found, returning None")
24
+ return None
25
+ else:
26
+ logger.info(f"Proxy string found in environment variables")
27
+
28
+ # Example: user:pass@host:port or just host:port
29
+ match = re.match(Patterns.PROXY.value, proxy_str)
30
+
31
+ proxy_dict = {}
32
+ if not match:
33
+ logger.warning(f"Proxy string did not match expected pattern, using basic formating")
34
+ proxy_dict['server'] = proxy_str
35
+
36
+ if not proxy_str.startswith('http://') and not proxy_str.startswith('https://'):
37
+ logger.warning("Proxy string missing protocol, prepending 'http://'")
38
+ proxy_dict['server'] = f"http://{proxy_str}"
39
+
40
+ logger.info(f"Proxy parsed as basic")
41
+ return proxy_dict
42
+ else:
43
+ match_dict = match.groupdict()
44
+ proxy_dict['server'] = f"{match_dict['scheme'] or 'http://'}{match_dict['host']}"
45
+ if match_dict['port']:
46
+ proxy_dict['server'] += f":{match_dict['port']}"
47
+
48
+ for key in ['username', 'password']:
49
+ if match_dict[key]:
50
+ proxy_dict[key] = match_dict[key]
51
+
52
+ logger.info(f"Proxy WITH{'OUT' if 'username' not in proxy_dict else ''} credentials")
53
+
54
+ logger.info(f"Proxy parsed as regex")
55
+ return proxy_dict
56
+
57
+ async def _parse_match(match: str, progress_bar: tqdm | None = None) -> dict:
58
+ result = {}
59
+
60
+ if progress_bar:
61
+ progress_bar.set_description("Parsing strings")
62
+
63
+ # Парсинг строк
64
+ string_matches = re.finditer(Patterns.STR.value, match)
65
+ for m in string_matches:
66
+ key, value = m.group(1), m.group(2)
67
+ result[key] = value.replace('\"', '"').replace('\\', '\\')
68
+
69
+ if progress_bar:
70
+ progress_bar.update(1)
71
+ progress_bar.set_description("Parsing dictionaries")
72
+
73
+ # Парсинг словарей
74
+ dict_matches = re.finditer(Patterns.DICT.value, match)
75
+ for m in dict_matches:
76
+ key, value = m.group(1), m.group(2)
77
+ if not re.search(Patterns.STR.value, value):
78
+ result[key] = await _parse_match(value, progress_bar)
79
+
80
+ if progress_bar:
81
+ progress_bar.update(1)
82
+ progress_bar.set_description("Parsing lists")
83
+
84
+ # Парсинг списков
85
+ list_matches = re.finditer(Patterns.LIST.value, match)
86
+ for m in list_matches:
87
+ key, value = m.group(1), m.group(2)
88
+ if not re.search(Patterns.STR.value, value):
89
+ result[key] = [await _parse_match(item.group(0), progress_bar) for item in re.finditer(Patterns.FIND.value, value)]
90
+
91
+ if progress_bar:
92
+ progress_bar.update(1)
93
+
94
+ return result
95
+
96
+ async def parse_js(js_code: str, debug: bool, logger) -> dict | None:
97
+ """
98
+ Парсит JavaScript-код и извлекает данные из переменной "n".
99
+
100
+ :param js_code: JS-код в виде строки.
101
+ :return: Распарсенные данные в виде словаря или None.
102
+ """
103
+ matches = re.finditer(Patterns.JS.value, js_code)
104
+ match_list = list(matches)
105
+
106
+ logger.debug(f'Found matches {len(match_list)}')
107
+
108
+ progress_bar = tqdm(total=33, desc="Parsing JS", position=0) if debug else None
109
+
110
+ if match_list and len(match_list) >= 1:
111
+ logger.info('Starting to parse match')
112
+ result = await _parse_match(match_list[1].group(0), progress_bar)
113
+
114
+ if progress_bar:
115
+ progress_bar.close()
116
+ logger.info('Complited parsing match')
117
+ return result
118
+ else:
119
+ if progress_bar:
120
+ progress_bar.close()
121
+ raise Exception("N variable in JS code not found")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyaterochka_api
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: A Python API client for Pyaterochka store catalog
5
5
  Home-page: https://github.com/Open-Inflation/pyaterochka_api
6
6
  Author: Miskler
@@ -22,6 +22,7 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: aiohttp
24
24
  Requires-Dist: camoufox[geoip]
25
+ Requires-Dist: beartype
25
26
  Requires-Dist: fake-useragent
26
27
  Requires-Dist: tqdm
27
28
  Provides-Extra: tests
@@ -43,6 +44,7 @@ Dynamic: summary
43
44
 
44
45
  Pyaterochka (Пятёрочка) - https://5ka.ru/
45
46
 
47
+ [![GitHub Actions](https://github.com/Open-Inflation/pyaterochka_api/workflows/API%20Tests%20Daily/badge.svg)](https://github.com/Open-Inflation/pyaterochka_api/actions?query=workflow%3A"API+Tests+Daily?query=branch%3Amain")
46
48
  ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyaterochka_api)
47
49
  ![PyPI - Package Version](https://img.shields.io/pypi/v/pyaterochka_api?color=blue)
48
50
  [![PyPI - Downloads](https://img.shields.io/pypi/dm/pyaterochka_api?label=PyPi%20downloads)](https://pypi.org/project/pyaterochka-api/)
@@ -67,12 +69,12 @@ camoufox fetch
67
69
 
68
70
  ### Usage / Использование:
69
71
  ```py
70
- from pyaterochka_api import Pyaterochka
72
+ from pyaterochka_api import Pyaterochka, PurchaseMode
71
73
  import asyncio
72
74
 
73
75
 
74
76
  async def main():
75
- async with Pyaterochka(proxy="user:password@host:port", debug=False, autoclose_browser=False) as API:
77
+ async with Pyaterochka(proxy="user:password@host:port", debug=False, autoclose_browser=False, trust_env=False) as API:
76
78
  # RUS: Вводим геоточку (самого магазина или рядом с ним) и получаем инфу о магазине
77
79
  # ENG: Enter a geolocation (of the store or near it) and get info about the store
78
80
  find_store = await API.find_store(longitude=37.63156, latitude=55.73768)
@@ -102,6 +104,12 @@ async def main():
102
104
  # 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
105
  API.autoclose_browser = True
104
106
 
107
+ # RUS: Напрямую передается в aiohttp, так же учитывается в браузере. В первую очередь нужен для использования системного `HTTPS_PROXY`.
108
+ # Но системный прокси применяется, только если не указали иное напрямую в `API.proxy`.
109
+ # ENG: Directly passed to aiohttp, also taken into account in the browser. Primarily needed for using the system `HTTPS_PROXY`.
110
+ # But the system proxy is applied only if you did not specify otherwise directly in `API.proxy`.
111
+ API.trust_env = True
112
+
105
113
  # RUS: Выводит список последних промо-акций/новостей (можно поставить ограничитель по количеству, опционально)
106
114
  # ENG: Outputs a list of the latest promotions/news (you can set a limit on the number, optionally)
107
115
  news = await API.get_news(limit=5)
@@ -121,13 +129,17 @@ async def main():
121
129
  with open(image.name, 'wb') as f:
122
130
  f.write(image.getbuffer())
123
131
 
132
+ # RUS: Можно указать свой таймаут (браузер может его интерпретировать как x2 т.к. там 2 итерации скачивания)
133
+ # ENG: You can specify your own timeout (the browser may interpret it as x2 since there are 2 iterations of downloading)
134
+ API.timeout = 7
135
+
124
136
  # RUS: Так же как и debug, в рантайме можно переназначить прокси
125
137
  # ENG: As with debug, you can reassign the proxy in runtime
126
138
  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()
139
+ # RUS: Изменения происходят сразу же, кроме product_info, т.к. за него отвечает браузер
140
+ # ENG: Changes take effect immediately, except for product_info, as it is handled by the browser
141
+ await API.rebuild_connection(session=False, browser=True)
142
+ await API.product_info(43347)
131
143
 
132
144
 
133
145
  if __name__ == '__main__':
@@ -0,0 +1,15 @@
1
+ pyaterochka_api/__init__.py,sha256=6XnalFPUumYqDJFyXw2puejJ612o-D1tYjZ_IjQ7Hx0,108
2
+ pyaterochka_api/api.py,sha256=KEr28n1aH69cVo0ztHCgB4ANVVZ-CezLWYTiPpfAmoc,7793
3
+ pyaterochka_api/enums.py,sha256=JnX4JiHzXyRo4se8sCFx0LyqcKlXXED0VcA0xI7r_ZI,621
4
+ pyaterochka_api/manager.py,sha256=yQw0njGCsropysT-_siuyNxXNpX-VJKOcVD8b-aV48I,10765
5
+ pyaterochka_api/tools.py,sha256=xFOThNRClX4u0cMfmQ5fVQKLM2Fn-rBAekzo_yBvRnQ,4447
6
+ pyaterochka_api-0.1.8.dist-info/licenses/LICENSE,sha256=Ee_P5XQUYoJuffzRL24j4GWpqgoWphUOKswpB2f9HcQ,1071
7
+ tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ tests/api_tests.py,sha256=XgOFu8yUKYqeRZ6QuQE-ZjMuPreQUb9y8iR7bDw-DOE,2763
9
+ tests/tools_tests.py,sha256=UKWVHu-QkEzc7iLsm5wIhA17FLq3E7SxwkLHc5GEI2M,1040
10
+ tests/snapshots/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ tests/snapshots/snap_base_tests.py,sha256=ryxaXZI10UonxclonGuLatjS3ZNMzkUeyz8uB0U5PdQ,27356
12
+ pyaterochka_api-0.1.8.dist-info/METADATA,sha256=3rFmk1lprX-iELCmBvC4-wFZE-EIOLx7UlZJECjgr_8,9305
13
+ pyaterochka_api-0.1.8.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
14
+ pyaterochka_api-0.1.8.dist-info/top_level.txt,sha256=PXTSi8y2C5_Mz20pJJFqOUBnjAIAXP_cc38mthvJ2x4,22
15
+ pyaterochka_api-0.1.8.dist-info/RECORD,,
@@ -14,7 +14,7 @@ def gen_schema(data):
14
14
 
15
15
  @pytest.mark.asyncio
16
16
  async def test_list(snapshot: SnapshotTest):
17
- async with Pyaterochka() as API:
17
+ async with Pyaterochka(debug=True, trust_env=True) as API:
18
18
  categories = await API.categories_list(subcategories=True)
19
19
  snapshot.assert_match(gen_schema(categories), "categories_list")
20
20
 
@@ -23,25 +23,25 @@ async def test_list(snapshot: SnapshotTest):
23
23
 
24
24
  @pytest.mark.asyncio
25
25
  async def test_product_info(snapshot: SnapshotTest):
26
- async with Pyaterochka() as API:
26
+ async with Pyaterochka(trust_env=True) as API:
27
27
  result = await API.product_info(43347)
28
28
  snapshot.assert_match(gen_schema(result), "product_info")
29
29
 
30
30
  @pytest.mark.asyncio
31
31
  async def test_get_news(snapshot: SnapshotTest):
32
- async with Pyaterochka() as API:
32
+ async with Pyaterochka(debug=True, trust_env=True) as API:
33
33
  result = await API.get_news(limit=5)
34
34
  snapshot.assert_match(gen_schema(result), "get_news")
35
35
 
36
36
  @pytest.mark.asyncio
37
37
  async def test_find_store(snapshot: SnapshotTest):
38
- async with Pyaterochka() as API:
38
+ async with Pyaterochka(debug=True, trust_env=True) as API:
39
39
  categories = await API.find_store(longitude=37.63156, latitude=55.73768)
40
40
  snapshot.assert_match(gen_schema(categories), "store_info")
41
41
 
42
42
  @pytest.mark.asyncio
43
43
  async def test_download_image(snapshot: SnapshotTest):
44
- async with Pyaterochka() as API:
44
+ async with Pyaterochka(debug=True, trust_env=True) as API:
45
45
  result = await API.download_image("https://photos.okolo.app/product/1392827-main/800x800.jpeg")
46
46
  assert isinstance(result, BytesIO)
47
47
  assert result.getvalue()
@@ -55,12 +55,12 @@ async def test_set_debug(snapshot: SnapshotTest):
55
55
 
56
56
  @pytest.mark.asyncio
57
57
  async def test_rebuild_connection(snapshot: SnapshotTest):
58
- async with Pyaterochka() as API:
58
+ async with Pyaterochka(debug=True, trust_env=True) as API:
59
59
  await API.rebuild_connection()
60
60
  snapshot.assert_match("connection has been rebuilt", "rebuild_connection")
61
61
 
62
- @pytest.mark.asyncio
63
- async def test_get_config(snapshot: SnapshotTest):
64
- async with Pyaterochka() as API:
65
- result = await API.get_config()
66
- snapshot.assert_match(gen_schema(result), "get_config")
62
+ #@pytest.mark.asyncio
63
+ #async def test_get_config(snapshot: SnapshotTest):
64
+ # async with Pyaterochka(debug=True, trust_env=True, timeout=30) as API:
65
+ # result = await API.get_config()
66
+ # snapshot.assert_match(gen_schema(result), "get_config")
@@ -667,6 +667,7 @@ snapshots['test_list products_list'] = {
667
667
  'str'
668
668
  ]
669
669
  },
670
+ 'initial_weight_step': 'str',
670
671
  'labels': [
671
672
  {
672
673
  'bg_color': 'str',
@@ -674,6 +675,7 @@ snapshots['test_list products_list'] = {
674
675
  'text_color': 'str'
675
676
  }
676
677
  ],
678
+ 'min_weight': 'str',
677
679
  'name': 'str',
678
680
  'plu': 'int',
679
681
  'prices': {
@@ -763,6 +765,7 @@ snapshots['test_product_info product_info'] = {
763
765
  },
764
766
  'ingredients': 'str',
765
767
  'ingredients_html': 'NoneType',
768
+ 'initial_weight_step': 'str',
766
769
  'is_available': 'bool',
767
770
  'is_various_manufacturers': 'bool',
768
771
  'labels': [
@@ -772,6 +775,7 @@ snapshots['test_product_info product_info'] = {
772
775
  'text_color': 'str'
773
776
  }
774
777
  ],
778
+ 'min_weight': 'str',
775
779
  'name': 'str',
776
780
  'nutrients': [
777
781
  {
tests/tools_tests.py ADDED
@@ -0,0 +1,30 @@
1
+ import pytest
2
+ from pyaterochka_api.tools import parse_proxy
3
+ import itertools
4
+ import logging
5
+
6
+ @pytest.mark.asyncio
7
+ async def test_parse_proxy():
8
+ # Варианты параметров
9
+ schemes = ['http://', 'https://', '']
10
+ auths = [('', ''), ('user', 'pass')]
11
+ hosts = ['127.0.0.1', 'example.com']
12
+ ports = ['', '8080']
13
+
14
+ logger = logging.getLogger("test_parse_proxy")
15
+
16
+ for scheme, (username, password), host, port in itertools.product(schemes, auths, hosts, ports):
17
+ # Формируем строку прокси
18
+ auth_part = f"{username}:{password}@" if username else ""
19
+ port_part = f":{port}" if port else ""
20
+ proxy_str = f"{scheme}{auth_part}{host}{port_part}"
21
+
22
+ expected = {'server': f"{scheme}{host}{port_part}"}
23
+ if not scheme:
24
+ expected['server'] = "http://"+expected['server']
25
+ if username:
26
+ expected['username'] = username
27
+ expected['password'] = password
28
+
29
+ assert parse_proxy(proxy_str, True, logger) == expected
30
+
@@ -1,12 +0,0 @@
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,,