plati-mcp-server 0.1.3 → 0.1.4

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.
Files changed (3) hide show
  1. package/mcp_server.py +130 -44
  2. package/package.json +1 -1
  3. package/plati_scrape.py +237 -88
package/mcp_server.py CHANGED
@@ -90,56 +90,142 @@ def find_cheapest_reliable_options(
90
90
  limit: int = 5,
91
91
  currency: str = "RUB",
92
92
  lang: str = "ru-RU",
93
- min_reviews: int = 500,
94
- min_positive_ratio: float = 0.98,
93
+ min_reviews: int = 0,
94
+ min_positive_ratio: float = 0.0,
95
95
  max_pages: int = 6,
96
96
  per_page: int = 30,
97
97
  ) -> Dict[str, Any]:
98
98
  if plati_scrape is None:
99
99
  raise RuntimeError(f"plati_scrape import failed: {_PLATI_IMPORT_ERROR}")
100
- search_url = f"https://plati.market/search/{quote(query, safe='')}"
101
- rows = plati_scrape.search_all_products(
102
- search_url=search_url,
103
- lang=lang,
104
- currency=currency,
105
- per_page=per_page,
106
- max_items=per_page * max_pages,
107
- sort_by="popular",
108
- max_pages=max_pages,
109
- )
110
-
111
- filtered: List[Dict[str, Any]] = []
112
- for row in rows:
113
- reviews = int(row.get("seller_reviews", 0) or 0)
114
- gb = _parse_good_bad(str(row.get("seller_good_bad", "0/0")))
115
- total = gb["good"] + gb["bad"]
116
- ratio = (gb["good"] / total) if total > 0 else 0.0
117
- if reviews < min_reviews:
118
- continue
119
- if ratio < min_positive_ratio:
120
- continue
121
- filtered.append(
122
- {
123
- "title": row.get("title", ""),
124
- "price": row.get("price", ""),
125
- "price_value": float(row.get("price_value", 0.0) or 0.0),
126
- "duration": row.get("duration", ""),
127
- "seller": row.get("seller", ""),
128
- "seller_reviews": reviews,
129
- "good": gb["good"],
130
- "bad": gb["bad"],
131
- "positive_ratio": round(ratio, 4),
132
- "link": row.get("link", ""),
133
- "pro_option": row.get("pro_choice", ""),
134
- }
135
- )
136
100
 
137
- filtered.sort(key=lambda r: (r["price_value"], -r["seller_reviews"]))
138
- top = filtered[: max(1, int(limit))]
101
+ q = quote(query, safe="")
102
+ lots: List[Dict[str, Any]] = []
103
+ seller_cache: Dict[int, Dict[str, Any]] = {}
104
+ page = 1
105
+
106
+ while page <= max_pages and len(lots) < max(1, int(limit)):
107
+ search_url = plati_scrape.build_search_url(q, page, per_page, currency, lang, "popular")
108
+ payload = plati_scrape.fetch_json(search_url)
109
+ content = payload.get("content") or {}
110
+ items = content.get("items") or []
111
+ if not items:
112
+ break
113
+
114
+ for item in items:
115
+ pid = int(item.get("product_id") or 0)
116
+ seller_id = int(item.get("seller_id") or 0)
117
+ if pid <= 0:
118
+ continue
119
+
120
+ try:
121
+ details = plati_scrape.fetch_json(plati_scrape.build_product_data_url(pid, currency, lang), timeout=12)
122
+ except Exception:
123
+ continue
124
+ if int(details.get("retval", -1)) != 0:
125
+ continue
126
+
127
+ product = details.get("product") or {}
128
+ if not product:
129
+ continue
130
+ if str(product.get("is_available", 1)).lower() in {"0", "false"}:
131
+ continue
132
+
133
+ base_price = float(product.get("price") or item.get("price") or 0.0)
134
+ title = plati_scrape.clean_text(str(product.get("name") or plati_scrape.pick_name(item.get("name") or [], lang)))
135
+ link = f"https://plati.market/itm/i/{pid}"
136
+ seller_name = str((product.get("seller") or {}).get("name") or item.get("seller_name") or "")
137
+
138
+ if seller_id > 0 and seller_id not in seller_cache:
139
+ seller_cache[seller_id] = {"total": 0, "good": 0, "bad": 0, "positive_ratio": 0.0}
140
+ try:
141
+ reviews = plati_scrape.fetch_json(plati_scrape.build_reviews_url(seller_id, lang), timeout=10)
142
+ good = int(reviews.get("totalGood", 0) or 0)
143
+ bad = int(reviews.get("totalBad", 0) or 0)
144
+ total = good + bad
145
+ seller_cache[seller_id] = {
146
+ "total": int(reviews.get("totalItems", 0) or 0),
147
+ "good": good,
148
+ "bad": bad,
149
+ "positive_ratio": (good / total) if total > 0 else 0.0,
150
+ }
151
+ except Exception:
152
+ pass
153
+ seller_info = seller_cache.get(seller_id, {"total": 0, "good": 0, "bad": 0, "positive_ratio": 0.0})
154
+ if int(seller_info.get("total", 0)) < min_reviews:
155
+ continue
156
+ if float(seller_info.get("positive_ratio", 0.0)) < min_positive_ratio:
157
+ continue
158
+
159
+ rates = plati_scrape._build_rate_map(product)
160
+ options_payload = []
161
+ min_option_price = base_price
162
+ for opt in (product.get("options") or []):
163
+ variants = [v for v in (opt.get("variants") or []) if int(v.get("visible", 1) or 0) == 1]
164
+ if not variants:
165
+ continue
166
+ option_variants = []
167
+ for v in variants:
168
+ delta = plati_scrape._modifier_value(v, base_price, currency, rates)
169
+ price_if_selected = max(base_price + delta, 0.0)
170
+ if price_if_selected < min_option_price:
171
+ min_option_price = price_if_selected
172
+ option_variants.append(
173
+ {
174
+ "value": v.get("value"),
175
+ "text": plati_scrape.clean_text(str(v.get("text", ""))),
176
+ "default": int(v.get("default", 0) or 0),
177
+ "modify": v.get("modify"),
178
+ "modify_type": v.get("modify_type"),
179
+ "modify_value": float(v.get("modify_value_default") or v.get("modify_value") or 0.0),
180
+ "price_if_selected": price_if_selected,
181
+ "price_if_selected_fmt": plati_scrape.format_price(price_if_selected, currency),
182
+ }
183
+ )
184
+ options_payload.append(
185
+ {
186
+ "id": opt.get("id"),
187
+ "name": opt.get("name"),
188
+ "label": opt.get("label"),
189
+ "type": opt.get("type"),
190
+ "required": int(opt.get("required", 0) or 0),
191
+ "variants": option_variants,
192
+ }
193
+ )
194
+
195
+ lots.append(
196
+ {
197
+ "product_id": pid,
198
+ "title": title,
199
+ "base_price": base_price,
200
+ "base_price_fmt": plati_scrape.format_price(base_price, currency),
201
+ "currency": currency,
202
+ "prices_default": (product.get("prices") or {}).get("default") or {},
203
+ "min_option_price": min_option_price,
204
+ "min_option_price_fmt": plati_scrape.format_price(min_option_price, currency),
205
+ "seller": seller_name,
206
+ "seller_reviews": seller_info.get("total", 0),
207
+ "good": seller_info.get("good", 0),
208
+ "bad": seller_info.get("bad", 0),
209
+ "positive_ratio": round(float(seller_info.get("positive_ratio", 0.0)), 4),
210
+ "link": link,
211
+ "options": options_payload,
212
+ }
213
+ )
214
+
215
+ if len(lots) >= max(1, int(limit)):
216
+ break
217
+ if len(lots) >= max(1, int(limit)):
218
+ break
219
+ if not content.get("has_next_page"):
220
+ break
221
+ page += 1
222
+
223
+ lots.sort(key=lambda x: (x["min_option_price"], -int(x.get("seller_reviews", 0))))
224
+ top = lots[: max(1, int(limit))]
139
225
  return {
140
226
  "query": query,
141
- "total_candidates": len(rows),
142
- "reliable_candidates": len(filtered),
227
+ "total_candidates": len(lots),
228
+ "reliable_candidates": len(lots),
143
229
  "returned": len(top),
144
230
  "items": top,
145
231
  }
@@ -155,8 +241,8 @@ TOOL_SCHEMA = {
155
241
  "limit": {"type": "integer", "default": 5, "minimum": 1, "maximum": 50},
156
242
  "currency": {"type": "string", "default": "RUB"},
157
243
  "lang": {"type": "string", "default": "ru-RU"},
158
- "min_reviews": {"type": "integer", "default": 500, "minimum": 0},
159
- "min_positive_ratio": {"type": "number", "default": 0.98, "minimum": 0, "maximum": 1},
244
+ "min_reviews": {"type": "integer", "default": 0, "minimum": 0},
245
+ "min_positive_ratio": {"type": "number", "default": 0.0, "minimum": 0, "maximum": 1},
160
246
  "max_pages": {"type": "integer", "default": 6, "minimum": 1, "maximum": 30},
161
247
  "per_page": {"type": "integer", "default": 30, "minimum": 5, "maximum": 100},
162
248
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plati-mcp-server",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "MCP stdio server for finding cheapest reliable subscription offers on Plati.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/plati_scrape.py CHANGED
@@ -27,6 +27,13 @@ SERVICE_OPTION_RX = re.compile(r"вариант|услуг|оказани|servic
27
27
  DURATION_OPTION_RX = re.compile(r"срок|duration|month|year|мес|год", flags=re.IGNORECASE)
28
28
  ACTIVATION_RX = re.compile(r"активац|продлен|activation|renewal", flags=re.IGNORECASE)
29
29
  ACCOUNT_CREATE_RX = re.compile(r"нет аккаунта|создайте|new account|выдач", flags=re.IGNORECASE)
30
+ PLUS_RX = re.compile(r"\bplus\b|плюс", flags=re.IGNORECASE)
31
+ GO_RX = re.compile(r"\bgo\b", flags=re.IGNORECASE)
32
+ BUSINESS_RX = re.compile(r"\bbusiness\b|бизнес", flags=re.IGNORECASE)
33
+ NOT_PRO_RX = re.compile(
34
+ r"\bне\s+нужн[а-я]*\s+подписк[а-я]*\s+pro\b|\bwithout\s+pro\b|\bno\s+pro\b|\bбез\s+pro\b",
35
+ flags=re.IGNORECASE,
36
+ )
30
37
  SEARCH_SORT_MAP = {
31
38
  "popular": "popular",
32
39
  "price_asc": "popular",
@@ -194,11 +201,75 @@ def _modifier_only_price(base_price: float, variants: List[Dict], currency: str,
194
201
  return max(total, 0.0)
195
202
 
196
203
 
197
- def extract_pro_choice(product_payload: Dict, base_price: float, currency: str) -> Optional[Tuple[float, str, str]]:
204
+ def _extract_duration_months(text: str) -> Optional[int]:
205
+ t = (text or "").lower()
206
+ m = re.search(r"(\d+)\s*(месяц|месяца|месяцев|мес|month|months|mo|m)\b", t)
207
+ if m:
208
+ return int(m.group(1))
209
+ y = re.search(r"(\d+)\s*(год|года|лет|year|years|yr)\b", t)
210
+ if y:
211
+ return int(y.group(1)) * 12
212
+ # "1-Month", "12-Month"
213
+ hm = re.search(r"(\d+)\s*[-–]\s*month\b", t)
214
+ if hm:
215
+ return int(hm.group(1))
216
+ return None
217
+
218
+
219
+ def _parse_request_preferences(request_text: str) -> Dict:
220
+ t = (request_text or "").lower()
221
+ plans = set()
222
+ if PRO_RX.search(t):
223
+ plans.add("pro")
224
+ if PLUS_RX.search(t):
225
+ plans.add("plus")
226
+ if GO_RX.search(t):
227
+ plans.add("go")
228
+ if BUSINESS_RX.search(t):
229
+ plans.add("business")
230
+ if not plans:
231
+ # By default expose all common subscription plans.
232
+ plans = {"pro", "plus", "go", "business"}
233
+
234
+ months = set()
235
+ for m in re.finditer(r"(\d+)\s*(месяц|месяца|месяцев|мес|month|months|mo|m)\b", t):
236
+ months.add(int(m.group(1)))
237
+ for y in re.finditer(r"(\d+)\s*(год|года|лет|year|years|yr)\b", t):
238
+ months.add(int(y.group(1)) * 12)
239
+ if re.search(r"\bгод\b|\byear\b|12\s*(месяц|месяцев|month|months)\b", t):
240
+ months.add(12)
241
+ if re.search(r"\bмесяц\b|\bmonth\b", t):
242
+ months.add(1)
243
+
244
+ return {"plans": plans, "months": months}
245
+
246
+
247
+ def _plan_tags(text: str) -> set:
248
+ low = (text or "").lower()
249
+ tags = set()
250
+ if PRO_RX.search(low) and not NOT_PRO_RX.search(low):
251
+ tags.add("pro")
252
+ if PLUS_RX.search(low):
253
+ tags.add("plus")
254
+ if GO_RX.search(low):
255
+ tags.add("go")
256
+ if BUSINESS_RX.search(low):
257
+ tags.add("business")
258
+ return tags
259
+
260
+
261
+ def extract_matching_choices(
262
+ product_payload: Dict,
263
+ base_price: float,
264
+ currency: str,
265
+ request_text: str,
266
+ return_all: bool = False,
267
+ ) -> List[Tuple[float, str, str]]:
198
268
  product = product_payload.get("product") or {}
199
269
  options = product.get("options") or []
200
270
  rates = _build_rate_map(product)
201
- candidates: List[Tuple[float, str, str]] = []
271
+ prefs = _parse_request_preferences(request_text)
272
+ candidates: List[Tuple[float, str, str, Optional[int]]] = []
202
273
 
203
274
  visible_options = []
204
275
  for option in options:
@@ -212,8 +283,21 @@ def extract_pro_choice(product_payload: Dict, base_price: float, currency: str)
212
283
  return d
213
284
  return variants[0]
214
285
 
215
- # Gather all PRO-capable variants from all selectable options.
216
- pro_variants: List[Tuple[Dict, Dict]] = []
286
+ duration_option: Optional[Tuple[Dict, List[Dict]]] = None
287
+ for option, variants in visible_options:
288
+ label_text = f"{option.get('label','')} {option.get('name','')}".lower()
289
+ if DURATION_OPTION_RX.search(label_text):
290
+ duration_option = (option, variants)
291
+ break
292
+ # Fallback: some products encode duration inside activation method option.
293
+ if duration_option is None:
294
+ for option, variants in visible_options:
295
+ if any(_extract_duration_months(clean_text(str(v.get("text", "")))) is not None for v in variants):
296
+ duration_option = (option, variants)
297
+ break
298
+
299
+ # Gather plan-capable variants from selectable options.
300
+ target_variants: List[Tuple[Dict, Dict]] = []
217
301
  for option, variants in visible_options:
218
302
  option_text = f"{option.get('label','')} {option.get('name','')}".lower()
219
303
  for variant in variants:
@@ -221,62 +305,116 @@ def extract_pro_choice(product_payload: Dict, base_price: float, currency: str)
221
305
  if not text:
222
306
  continue
223
307
  is_subscription_context = SUBSCRIPTION_RX.search(text) or SERVICE_OPTION_RX.search(option_text)
224
- if PRO_RX.search(text) and is_subscription_context and not API_OFFER_RX.search(text):
225
- pro_variants.append((option, variant))
226
-
227
- if not pro_variants:
228
- return None
229
-
230
- for pro_option, pro_variant in pro_variants:
231
- selected: Dict[int, Dict] = {}
232
- duration_text = ""
233
- for option, variants in visible_options:
234
- key = int(option.get("id") or 0)
235
- choice = default_variant(option, variants)
236
- label_text = f"{option.get('label','')} {option.get('name','')}".lower()
237
- is_activation_option = ACTIVATION_RX.search(label_text) or "тип подпис" in label_text
238
-
239
- if option is pro_option:
240
- choice = pro_variant
308
+ tags = _plan_tags(text.lower())
309
+ # Common pattern: dedicated PRO toggle where "not needed" means base/Plus path.
310
+ if "подписк" in option_text and "pro" in option_text and NOT_PRO_RX.search(text.lower()):
311
+ tags.add("plus")
312
+ if tags & prefs["plans"] and is_subscription_context and not API_OFFER_RX.search(text):
313
+ target_variants.append((option, variant))
314
+
315
+ if not target_variants:
316
+ return []
317
+
318
+ for target_option, target_variant in target_variants:
319
+ duration_candidates: List[Optional[Dict]] = [None]
320
+ if duration_option:
321
+ d_opt, d_vars = duration_option
322
+ all_duration_vars = [v for v in d_vars if _extract_duration_months(clean_text(str(v.get("text", "")))) is not None]
323
+ if prefs["months"]:
324
+ filtered = [
325
+ v for v in all_duration_vars
326
+ if (_extract_duration_months(clean_text(str(v.get("text", "")))) in prefs["months"])
327
+ ]
328
+ duration_candidates = filtered or [default_variant(d_opt, d_vars)]
241
329
  else:
242
- # Prefer "activation/renewal" style option over account-creation variants.
243
- if is_activation_option:
244
- act = next(
245
- (v for v in variants if ACTIVATION_RX.search(clean_text(str(v.get("text", "")).lower()))),
246
- None,
247
- )
248
- if act is not None:
249
- choice = act
250
- # Avoid explicit account creation variants when alternatives exist.
251
- if ACCOUNT_CREATE_RX.search(clean_text(str(choice.get("text", "")).lower())):
252
- better = next(
253
- (v for v in variants if not ACCOUNT_CREATE_RX.search(clean_text(str(v.get("text", "")).lower()))),
254
- None,
330
+ duration_candidates = all_duration_vars or [default_variant(d_opt, d_vars)]
331
+
332
+ for duration_variant in duration_candidates:
333
+ selected: Dict[int, Dict] = {}
334
+ duration_text = ""
335
+ for option, variants in visible_options:
336
+ key = int(option.get("id") or 0)
337
+ choice = default_variant(option, variants)
338
+ label_text = f"{option.get('label','')} {option.get('name','')}".lower()
339
+ is_activation_option = ACTIVATION_RX.search(label_text) or "тип подпис" in label_text
340
+
341
+ if option is target_option:
342
+ choice = target_variant
343
+ elif duration_option and option is duration_option[0] and duration_variant is not None:
344
+ choice = duration_variant
345
+ else:
346
+ # Prefer "activation/renewal" style option over account-creation variants.
347
+ if is_activation_option:
348
+ act = next(
349
+ (v for v in variants if ACTIVATION_RX.search(clean_text(str(v.get("text", "")).lower()))),
350
+ None,
351
+ )
352
+ if act is not None:
353
+ choice = act
354
+ # Avoid explicit account creation variants when alternatives exist.
355
+ if ACCOUNT_CREATE_RX.search(clean_text(str(choice.get("text", "")).lower())):
356
+ better = next(
357
+ (v for v in variants if not ACCOUNT_CREATE_RX.search(clean_text(str(v.get("text", "")).lower()))),
358
+ None,
359
+ )
360
+ if better is not None:
361
+ choice = better
362
+
363
+ if DURATION_OPTION_RX.search(label_text):
364
+ duration_text = clean_text(str(choice.get("text", "")))
365
+
366
+ # For unrelated options choose the least-cost visible variant.
367
+ if (
368
+ option is not target_option
369
+ and not DURATION_OPTION_RX.search(label_text)
370
+ and not is_activation_option
371
+ ):
372
+ cheapest = min(
373
+ variants,
374
+ key=lambda v: _modifier_value(v, base_price, currency, rates),
255
375
  )
256
- if better is not None:
257
- choice = better
258
-
259
- if DURATION_OPTION_RX.search(label_text):
260
- duration_text = clean_text(str(choice.get("text", "")))
261
-
262
- # For unrelated options choose the least-cost visible variant.
263
- if option is not pro_option and not DURATION_OPTION_RX.search(label_text) and not is_activation_option:
264
- cheapest = min(
265
- variants,
266
- key=lambda v: _modifier_value(v, base_price, currency, rates),
267
- )
268
- if _modifier_value(cheapest, base_price, currency, rates) < _modifier_value(choice, base_price, currency, rates):
269
- choice = cheapest
270
-
271
- selected[key] = choice
272
-
273
- total = _modifier_only_price(base_price, list(selected.values()), currency, rates)
274
-
275
- pro_text = clean_text(str(pro_variant.get("text", "")))
276
- duration = extract_duration(pro_text) or extract_duration(duration_text)
277
- candidates.append((max(total, 0.0), pro_text, duration))
376
+ if _modifier_value(cheapest, base_price, currency, rates) < _modifier_value(choice, base_price, currency, rates):
377
+ choice = cheapest
378
+
379
+ selected[key] = choice
380
+
381
+ total = _modifier_only_price(base_price, list(selected.values()), currency, rates)
382
+ choice_text = clean_text(str(target_variant.get("text", "")))
383
+ joined_selection_text = " ".join(clean_text(str(v.get("text", ""))) for v in selected.values())
384
+ duration = extract_duration(duration_text) or extract_duration(choice_text) or extract_duration(joined_selection_text)
385
+ duration_months = _extract_duration_months(duration_text) or _extract_duration_months(choice_text)
386
+ if duration_months is None:
387
+ duration_months = _extract_duration_months(joined_selection_text)
388
+ if API_OFFER_RX.search(choice_text):
389
+ continue
390
+ candidates.append((max(total, 0.0), choice_text, duration, duration_months))
391
+
392
+ if not candidates:
393
+ return []
394
+
395
+ # Deduplicate and keep cheapest for each text/duration.
396
+ dedup: Dict[Tuple[str, str], Tuple[float, str, str, Optional[int]]] = {}
397
+ for c in candidates:
398
+ key = (c[1], c[2])
399
+ if key not in dedup or c[0] < dedup[key][0]:
400
+ dedup[key] = c
401
+ ordered = sorted(dedup.values(), key=lambda x: x[0])
402
+
403
+ if prefs["months"]:
404
+ best_by_month: Dict[int, Tuple[float, str, str, Optional[int]]] = {}
405
+ for c in ordered:
406
+ m = c[3]
407
+ if m is None:
408
+ continue
409
+ if m in prefs["months"] and (m not in best_by_month or c[0] < best_by_month[m][0]):
410
+ best_by_month[m] = c
411
+ if best_by_month:
412
+ return [best_by_month[m][:3] for m in sorted(best_by_month.keys())]
278
413
 
279
- return min(candidates, key=lambda x: x[0])
414
+ if return_all:
415
+ return [c[:3] for c in ordered]
416
+ # Default: return cheapest relevant choice.
417
+ return [ordered[0][:3]]
280
418
 
281
419
 
282
420
  def classify_offer(
@@ -284,6 +422,8 @@ def classify_offer(
284
422
  fallback_title: str,
285
423
  base_price: float,
286
424
  currency: str,
425
+ request_text: str,
426
+ return_all_choices: bool = False,
287
427
  ) -> Optional[Dict]:
288
428
  if int(details.get("retval", -1)) != 0:
289
429
  return None
@@ -297,22 +437,21 @@ def classify_offer(
297
437
  return None
298
438
 
299
439
  title = clean_text(str(product.get("name") or fallback_title))
300
- pro_choice = extract_pro_choice(details, base_price, currency)
301
- if not pro_choice:
440
+ choices = extract_matching_choices(
441
+ details,
442
+ base_price,
443
+ currency,
444
+ request_text=request_text,
445
+ return_all=return_all_choices,
446
+ )
447
+ if not choices:
302
448
  return None
303
- pro_choice_text = pro_choice[1]
304
- displayed_price = float(pro_choice[0])
305
- parsed_duration = pro_choice[2]
306
-
307
- text_for_classification = f"{title} {pro_choice_text}"
308
- # Exclude API-key/token goods, keep only subscription-like PRO offers.
309
- if API_OFFER_RX.search(text_for_classification):
449
+ # Exclude API-key/token goods.
450
+ if API_OFFER_RX.search(title):
310
451
  return None
311
452
  return {
312
- "price_value": displayed_price,
313
- "pro_choice_text": pro_choice_text,
314
453
  "title": title,
315
- "duration": parsed_duration,
454
+ "choices": [{"price_value": float(c[0]), "choice_text": c[1], "duration": c[2]} for c in choices],
316
455
  }
317
456
 
318
457
 
@@ -324,6 +463,8 @@ def search_all_products(
324
463
  max_items: int,
325
464
  sort_by: str,
326
465
  max_pages: int,
466
+ request_text: str = "pro",
467
+ return_all_choices: bool = False,
327
468
  ) -> List[Dict]:
328
469
  query = parse_search_query(search_url)
329
470
  rows = []
@@ -358,26 +499,27 @@ def search_all_products(
358
499
  pid = item.get("product_id")
359
500
  seller_id = int(item.get("seller_id") or 0)
360
501
  base_price = float(item.get("price", 0))
361
- displayed_price = base_price
362
- pro_choice_text = ""
363
- duration = extract_duration(name)
364
502
  seller_reviews = {"total": 0, "good": 0, "bad": 0}
365
503
 
366
504
  if pid not in product_cache:
367
505
  product_cache[pid] = None
368
506
  try:
369
507
  details = fetch_json(build_product_data_url(int(pid), currency, lang), timeout=12)
370
- product_cache[pid] = classify_offer(details, name, base_price, currency)
508
+ product_cache[pid] = classify_offer(
509
+ details,
510
+ name,
511
+ base_price,
512
+ currency,
513
+ request_text=request_text,
514
+ return_all_choices=return_all_choices,
515
+ )
371
516
  except Exception:
372
517
  product_cache[pid] = None
373
518
 
374
519
  offer = product_cache[pid]
375
520
  if not offer:
376
521
  continue
377
- displayed_price = float(offer["price_value"])
378
- pro_choice_text = str(offer["pro_choice_text"])
379
522
  name = str(offer["title"])
380
- duration = str(offer.get("duration") or "") or extract_duration(pro_choice_text) or extract_duration(name) or duration
381
523
 
382
524
  if seller_id > 0:
383
525
  if seller_id not in seller_cache:
@@ -393,18 +535,24 @@ def search_all_products(
393
535
  seller_cache[seller_id] = {"total": 0, "good": 0, "bad": 0}
394
536
  seller_reviews = seller_cache[seller_id]
395
537
 
396
- row = {
397
- "title": name,
398
- "price": format_price(displayed_price, currency),
399
- "price_value": displayed_price,
400
- "duration": duration,
401
- "seller": str(item.get("seller_name", "")),
402
- "seller_reviews": seller_reviews["total"],
403
- "seller_good_bad": f"{seller_reviews['good']}/{seller_reviews['bad']}",
404
- "link": f"https://plati.market/itm/i/{pid}",
405
- "pro_choice": pro_choice_text,
406
- }
407
- rows.append(row)
538
+ for choice in offer.get("choices", []):
539
+ displayed_price = float(choice.get("price_value", base_price))
540
+ choice_text = str(choice.get("choice_text", ""))
541
+ duration = str(choice.get("duration", "")) or extract_duration(choice_text) or extract_duration(name) or ""
542
+ row = {
543
+ "title": name,
544
+ "price": format_price(displayed_price, currency),
545
+ "price_value": displayed_price,
546
+ "duration": duration,
547
+ "seller": str(item.get("seller_name", "")),
548
+ "seller_reviews": seller_reviews["total"],
549
+ "seller_good_bad": f"{seller_reviews['good']}/{seller_reviews['bad']}",
550
+ "link": f"https://plati.market/itm/i/{pid}",
551
+ "pro_choice": choice_text,
552
+ }
553
+ rows.append(row)
554
+ if len(rows) >= max_items:
555
+ break
408
556
  if len(rows) >= max_items:
409
557
  break
410
558
 
@@ -686,6 +834,7 @@ def main() -> int:
686
834
  max_items=args.max_items,
687
835
  sort_by=args.sort_by,
688
836
  max_pages=args.max_pages,
837
+ request_text=parse_search_query(args.url),
689
838
  )
690
839
  if args.cost_sort == "asc":
691
840
  rows.sort(key=lambda r: float(r.get("price_value", 0.0)))