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.
- package/mcp_server.py +130 -44
- package/package.json +1 -1
- 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 =
|
|
94
|
-
min_positive_ratio: float = 0.
|
|
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
|
-
|
|
138
|
-
|
|
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(
|
|
142
|
-
"reliable_candidates": len(
|
|
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":
|
|
159
|
-
"min_positive_ratio": {"type": "number", "default": 0.
|
|
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
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
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
257
|
-
choice =
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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":
|
|
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(
|
|
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
|
-
|
|
397
|
-
"
|
|
398
|
-
"
|
|
399
|
-
"
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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)))
|