plati-mcp-server 0.1.0

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/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # plati-mcp-server
2
+
3
+ MCP stdio server for querying Plati offers and returning cheapest reliable subscription options.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i -g plati-mcp-server
9
+ ```
10
+
11
+ ## Run
12
+
13
+ ```bash
14
+ plati-mcp-server
15
+ ```
16
+
17
+ ## OpenClaw / Claude MCP config
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "plati-scraper": {
23
+ "command": "plati-mcp-server",
24
+ "args": []
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ ## Exposed tool
31
+
32
+ - `find_cheapest_reliable_options`
33
+
34
+ Arguments:
35
+
36
+ - `query` (required)
37
+ - `limit` (default: `5`)
38
+ - `currency` (default: `RUB`)
39
+ - `lang` (default: `ru-RU`)
40
+ - `min_reviews` (default: `500`)
41
+ - `min_positive_ratio` (default: `0.98`)
42
+ - `max_pages` (default: `6`)
43
+ - `per_page` (default: `30`)
44
+
45
+ ## Notes
46
+
47
+ - Requires `python3` in `PATH`.
48
+ - Uses public Digiseller/Plati API endpoints.
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ const { spawn } = require("node:child_process");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const projectRoot = path.resolve(__dirname, "..");
7
+ const serverPath = path.join(projectRoot, "mcp_server.py");
8
+ const args = process.argv.slice(2);
9
+
10
+ if (!fs.existsSync(serverPath)) {
11
+ console.error(`Error: mcp_server.py not found at ${serverPath}`);
12
+ process.exit(1);
13
+ }
14
+
15
+ const child = spawn("python3", [serverPath, ...args], {
16
+ stdio: "inherit",
17
+ env: process.env,
18
+ });
19
+
20
+ child.on("error", (err) => {
21
+ if (err && err.code === "ENOENT") {
22
+ console.error("Error: python3 is not installed or not in PATH.");
23
+ } else {
24
+ console.error(`Error starting server: ${err.message}`);
25
+ }
26
+ process.exit(1);
27
+ });
28
+
29
+ child.on("exit", (code, signal) => {
30
+ if (signal) {
31
+ process.kill(process.pid, signal);
32
+ return;
33
+ }
34
+ process.exit(code ?? 0);
35
+ });
package/mcp_server.py ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import sys
4
+ from typing import Any, Dict, List
5
+ from urllib.parse import quote
6
+
7
+ import plati_scrape
8
+
9
+
10
+ def _read_message() -> Dict[str, Any]:
11
+ headers: Dict[str, str] = {}
12
+ while True:
13
+ line = sys.stdin.buffer.readline()
14
+ if not line:
15
+ raise EOFError
16
+ if line in (b"\r\n", b"\n"):
17
+ break
18
+ key, value = line.decode("utf-8").split(":", 1)
19
+ headers[key.strip().lower()] = value.strip()
20
+ content_length = int(headers.get("content-length", "0"))
21
+ payload = sys.stdin.buffer.read(content_length)
22
+ return json.loads(payload.decode("utf-8"))
23
+
24
+
25
+ def _write_message(msg: Dict[str, Any]) -> None:
26
+ data = json.dumps(msg, ensure_ascii=False).encode("utf-8")
27
+ header = f"Content-Length: {len(data)}\r\n\r\n".encode("utf-8")
28
+ sys.stdout.buffer.write(header)
29
+ sys.stdout.buffer.write(data)
30
+ sys.stdout.buffer.flush()
31
+
32
+
33
+ def _ok(req_id: Any, result: Dict[str, Any]) -> Dict[str, Any]:
34
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
35
+
36
+
37
+ def _err(req_id: Any, code: int, message: str) -> Dict[str, Any]:
38
+ return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
39
+
40
+
41
+ def _parse_good_bad(value: str) -> Dict[str, int]:
42
+ try:
43
+ good_s, bad_s = value.split("/", 1)
44
+ return {"good": int(good_s), "bad": int(bad_s)}
45
+ except Exception:
46
+ return {"good": 0, "bad": 0}
47
+
48
+
49
+ def find_cheapest_reliable_options(
50
+ query: str,
51
+ limit: int = 5,
52
+ currency: str = "RUB",
53
+ lang: str = "ru-RU",
54
+ min_reviews: int = 500,
55
+ min_positive_ratio: float = 0.98,
56
+ max_pages: int = 6,
57
+ per_page: int = 30,
58
+ ) -> Dict[str, Any]:
59
+ search_url = f"https://plati.market/search/{quote(query, safe='')}"
60
+ rows = plati_scrape.search_all_products(
61
+ search_url=search_url,
62
+ lang=lang,
63
+ currency=currency,
64
+ per_page=per_page,
65
+ max_items=per_page * max_pages,
66
+ sort_by="popular",
67
+ max_pages=max_pages,
68
+ )
69
+
70
+ filtered: List[Dict[str, Any]] = []
71
+ for row in rows:
72
+ reviews = int(row.get("seller_reviews", 0) or 0)
73
+ gb = _parse_good_bad(str(row.get("seller_good_bad", "0/0")))
74
+ total = gb["good"] + gb["bad"]
75
+ ratio = (gb["good"] / total) if total > 0 else 0.0
76
+ if reviews < min_reviews:
77
+ continue
78
+ if ratio < min_positive_ratio:
79
+ continue
80
+ filtered.append(
81
+ {
82
+ "title": row.get("title", ""),
83
+ "price": row.get("price", ""),
84
+ "price_value": float(row.get("price_value", 0.0) or 0.0),
85
+ "duration": row.get("duration", ""),
86
+ "seller": row.get("seller", ""),
87
+ "seller_reviews": reviews,
88
+ "good": gb["good"],
89
+ "bad": gb["bad"],
90
+ "positive_ratio": round(ratio, 4),
91
+ "link": row.get("link", ""),
92
+ "pro_option": row.get("pro_choice", ""),
93
+ }
94
+ )
95
+
96
+ filtered.sort(key=lambda r: (r["price_value"], -r["seller_reviews"]))
97
+ top = filtered[: max(1, int(limit))]
98
+ return {
99
+ "query": query,
100
+ "total_candidates": len(rows),
101
+ "reliable_candidates": len(filtered),
102
+ "returned": len(top),
103
+ "items": top,
104
+ }
105
+
106
+
107
+ TOOL_SCHEMA = {
108
+ "name": "find_cheapest_reliable_options",
109
+ "description": "Find cheapest reliable Plati offers for PRO subscriptions.",
110
+ "inputSchema": {
111
+ "type": "object",
112
+ "properties": {
113
+ "query": {"type": "string", "description": "Search term, e.g. 'claude code'"},
114
+ "limit": {"type": "integer", "default": 5, "minimum": 1, "maximum": 50},
115
+ "currency": {"type": "string", "default": "RUB"},
116
+ "lang": {"type": "string", "default": "ru-RU"},
117
+ "min_reviews": {"type": "integer", "default": 500, "minimum": 0},
118
+ "min_positive_ratio": {"type": "number", "default": 0.98, "minimum": 0, "maximum": 1},
119
+ "max_pages": {"type": "integer", "default": 6, "minimum": 1, "maximum": 30},
120
+ "per_page": {"type": "integer", "default": 30, "minimum": 5, "maximum": 100},
121
+ },
122
+ "required": ["query"],
123
+ },
124
+ }
125
+
126
+
127
+ def _handle_request(msg: Dict[str, Any]) -> Dict[str, Any]:
128
+ method = msg.get("method")
129
+ req_id = msg.get("id")
130
+ params = msg.get("params") or {}
131
+
132
+ if method == "initialize":
133
+ return _ok(
134
+ req_id,
135
+ {
136
+ "protocolVersion": "2024-11-05",
137
+ "capabilities": {"tools": {}},
138
+ "serverInfo": {"name": "plati-scraper-mcp", "version": "0.1.0"},
139
+ },
140
+ )
141
+ if method == "tools/list":
142
+ return _ok(req_id, {"tools": [TOOL_SCHEMA]})
143
+ if method == "tools/call":
144
+ name = params.get("name")
145
+ args = params.get("arguments") or {}
146
+ if name != "find_cheapest_reliable_options":
147
+ return _err(req_id, -32602, f"Unknown tool: {name}")
148
+ try:
149
+ result = find_cheapest_reliable_options(
150
+ query=str(args["query"]),
151
+ limit=int(args.get("limit", 5)),
152
+ currency=str(args.get("currency", "RUB")),
153
+ lang=str(args.get("lang", "ru-RU")),
154
+ min_reviews=int(args.get("min_reviews", 500)),
155
+ min_positive_ratio=float(args.get("min_positive_ratio", 0.98)),
156
+ max_pages=int(args.get("max_pages", 6)),
157
+ per_page=int(args.get("per_page", 30)),
158
+ )
159
+ return _ok(
160
+ req_id,
161
+ {
162
+ "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}],
163
+ "structuredContent": result,
164
+ },
165
+ )
166
+ except KeyError:
167
+ return _err(req_id, -32602, "Missing required argument: query")
168
+ except Exception as e:
169
+ return _ok(req_id, {"isError": True, "content": [{"type": "text", "text": f"Error: {e}"}]})
170
+
171
+ return _err(req_id, -32601, f"Method not found: {method}")
172
+
173
+
174
+ def main() -> int:
175
+ while True:
176
+ try:
177
+ msg = _read_message()
178
+ except EOFError:
179
+ return 0
180
+ except Exception:
181
+ continue
182
+
183
+ if "id" not in msg:
184
+ # Notification: ignore.
185
+ continue
186
+ response = _handle_request(msg)
187
+ _write_message(response)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ raise SystemExit(main())
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "plati-mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP stdio server for finding cheapest reliable subscription offers on Plati.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "plati-mcp-server": "bin/plati-mcp-server.js"
9
+ },
10
+ "files": [
11
+ "bin/plati-mcp-server.js",
12
+ "mcp_server.py",
13
+ "plati_scrape.py",
14
+ "README.md"
15
+ ],
16
+ "keywords": [
17
+ "mcp",
18
+ "openclaw",
19
+ "plati",
20
+ "scraper"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "scripts": {
26
+ "start": "plati-mcp-server",
27
+ "pack:check": "npm pack --dry-run"
28
+ }
29
+ }
@@ -0,0 +1,708 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import html
4
+ import json
5
+ import re
6
+ import sys
7
+ from typing import Dict, List, Optional, Tuple, Union
8
+ from urllib.error import HTTPError
9
+ from urllib.parse import quote, unquote, urlencode, urlparse
10
+ from urllib.request import Request, urlopen
11
+
12
+
13
+ SEARCH_ENDPOINT = "https://api.digiseller.com/api/cataloguer/front/products"
14
+ PRODUCT_DATA_ENDPOINT = "https://api.digiseller.com/api/products/{product_id}/data"
15
+ REVIEWS_ENDPOINT = "https://api.digiseller.com/api/reviews"
16
+ USER_AGENT = (
17
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
18
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
19
+ )
20
+ PRO_RX = re.compile(r"\bpro\b|\bпро\b", flags=re.IGNORECASE)
21
+ API_OFFER_RX = re.compile(r"\bapi\b|api[\s_-]*key|token|токен|ключ", flags=re.IGNORECASE)
22
+ SUBSCRIPTION_RX = re.compile(
23
+ r"подпис|subscription|месяц|month|год|year|активац|продлен",
24
+ flags=re.IGNORECASE,
25
+ )
26
+ SERVICE_OPTION_RX = re.compile(r"вариант|услуг|оказани|service|plan|тариф|подпис", flags=re.IGNORECASE)
27
+ DURATION_OPTION_RX = re.compile(r"срок|duration|month|year|мес|год", flags=re.IGNORECASE)
28
+ ACTIVATION_RX = re.compile(r"активац|продлен|activation|renewal", flags=re.IGNORECASE)
29
+ ACCOUNT_CREATE_RX = re.compile(r"нет аккаунта|создайте|new account|выдач", flags=re.IGNORECASE)
30
+ SEARCH_SORT_MAP = {
31
+ "popular": "popular",
32
+ "price_asc": "popular",
33
+ "price_desc": "popular",
34
+ "new": "popular",
35
+ }
36
+
37
+
38
+ def fetch_json(url: str, timeout: int = 30) -> Dict:
39
+ req = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
40
+ with urlopen(req, timeout=timeout) as resp:
41
+ return json.loads(resp.read().decode("utf-8", errors="replace"))
42
+
43
+
44
+ def parse_search_query(search_url: str) -> str:
45
+ parsed = urlparse(search_url)
46
+ m = re.search(r"/search/([^/?#]+)", parsed.path)
47
+ if not m:
48
+ raise ValueError("Expected a search URL like https://plati.market/search/chatgpt")
49
+ return unquote(m.group(1))
50
+
51
+
52
+ def build_search_url(query: str, page: int, count: int, currency: str, lang: str, sort_by: str) -> str:
53
+ params = {
54
+ "categoryId": "",
55
+ "getProductsRecursive": "true",
56
+ "sellerCategoryId": "",
57
+ "productId": "",
58
+ "productName": query,
59
+ "ownerId": "plati",
60
+ "ownerCategoryId": "",
61
+ "sellerId": "",
62
+ "sellerName": "",
63
+ "currency": currency,
64
+ "page": str(page),
65
+ "count": str(count),
66
+ "individual": "false",
67
+ "video": "false",
68
+ "image": "false",
69
+ "sortBy": sort_by,
70
+ "priceFrom": "",
71
+ "priceTo": "",
72
+ "includeAggregations": "true",
73
+ "fuzzy": "false",
74
+ "lang": lang,
75
+ }
76
+ return f"{SEARCH_ENDPOINT}?{urlencode(params)}"
77
+
78
+
79
+ def build_product_data_url(product_id: int, currency: str, lang: str) -> str:
80
+ params = {
81
+ "lang": lang,
82
+ "currency": currency,
83
+ "showHiddenVariants": "1",
84
+ }
85
+ return f"{PRODUCT_DATA_ENDPOINT.format(product_id=product_id)}?{urlencode(params)}"
86
+
87
+
88
+ def build_reviews_url(seller_id: int, lang: str) -> str:
89
+ params = {
90
+ "seller_id": str(seller_id),
91
+ "owner_id": "1",
92
+ "type": "all",
93
+ "page": "1",
94
+ "rows": "1",
95
+ "lang": lang,
96
+ }
97
+ return f"{REVIEWS_ENDPOINT}?{urlencode(params)}"
98
+
99
+
100
+ def normalize_search_sort(sort_by: str) -> str:
101
+ return SEARCH_SORT_MAP.get(sort_by, "popular")
102
+
103
+
104
+ def pick_name(name_entries: List[Dict], lang: str) -> str:
105
+ if not name_entries:
106
+ return ""
107
+ for entry in name_entries:
108
+ if entry.get("locale") == lang:
109
+ return clean_text(str(entry.get("value", "")))
110
+ for entry in name_entries:
111
+ if entry.get("locale", "").startswith(lang.split("-")[0]):
112
+ return clean_text(str(entry.get("value", "")))
113
+ return clean_text(str(name_entries[0].get("value", "")))
114
+
115
+
116
+ def clean_text(text: str) -> str:
117
+ return re.sub(r"\s+", " ", text).strip()
118
+
119
+
120
+ def extract_duration(title: str) -> str:
121
+ t = title.lower()
122
+ patterns = [
123
+ (r"(\d+)\s*[-–/]\s*(\d+)\s*(мес|месяц|месяца|месяцев|м|month|months|mo|m)\b", "{0}-{1} months"),
124
+ (r"(\d+)\s*[-–]?\s*(мес|месяц|месяца|месяцев|м|month|months|mo|m)\b", "{0} months"),
125
+ (r"(\d+)\s*(год|года|лет|year|years|yr)\b", "{0} years"),
126
+ (r"(\d+)\s*(дн|день|дня|дней|day|days)\b", "{0} days"),
127
+ ]
128
+ for rx, fmt in patterns:
129
+ m = re.search(rx, t, flags=re.IGNORECASE)
130
+ if not m:
131
+ continue
132
+ if "{1}" in fmt:
133
+ return fmt.format(m.group(1), m.group(2))
134
+ return fmt.format(m.group(1))
135
+ return ""
136
+
137
+
138
+ def format_price(value: Union[float, int], currency: str) -> str:
139
+ if int(value) == float(value):
140
+ num = f"{int(value):,}".replace(",", " ")
141
+ else:
142
+ num = f"{value:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
143
+ symbol = "₽" if currency.upper() == "RUB" else currency.upper()
144
+ return f"{num} {symbol}"
145
+
146
+
147
+ def _build_rate_map(product: Dict) -> Dict[str, float]:
148
+ prices = (product.get("prices") or {}).get("default") or {}
149
+ rates: Dict[str, float] = {}
150
+ for k, v in prices.items():
151
+ try:
152
+ rates[str(k).upper()] = float(v)
153
+ except Exception:
154
+ continue
155
+ return rates
156
+
157
+
158
+ def _modifier_value(variant: Dict, base_price: float, currency: str, rates: Dict[str, float]) -> float:
159
+ modify_value = variant.get("modify_value_default")
160
+ if modify_value is None:
161
+ modify_value = variant.get("modify_value", 0)
162
+ amount = float(modify_value or 0)
163
+ modify_type = str(variant.get("modify_type") or "").upper()
164
+
165
+ if modify_type in {"", currency.upper()}:
166
+ return amount
167
+ target = currency.upper()
168
+ if modify_type in rates and target in rates and rates[modify_type] > 0:
169
+ return amount * (rates[target] / rates[modify_type])
170
+ if modify_type in {"%", "PERCENT"}:
171
+ return base_price * amount / 100.0
172
+ return amount
173
+
174
+
175
+ def _default_variant(option: Dict) -> Optional[Dict]:
176
+ variants = option.get("variants") or []
177
+ for variant in variants:
178
+ if int(variant.get("default", 0) or 0) == 1:
179
+ return variant
180
+ return variants[0] if variants else None
181
+
182
+
183
+ def _compute_variant_price(base_price: float, option: Dict, variant: Dict, currency: str, rates: Dict[str, float]) -> float:
184
+ default = _default_variant(option)
185
+ default_delta = _modifier_value(default, base_price, currency, rates) if default else 0.0
186
+ selected_delta = _modifier_value(variant, base_price, currency, rates)
187
+ return max(base_price - default_delta + selected_delta, 0.0)
188
+
189
+
190
+ def _modifier_only_price(base_price: float, variants: List[Dict], currency: str, rates: Dict[str, float]) -> float:
191
+ total = float(base_price)
192
+ for v in variants:
193
+ total += _modifier_value(v, base_price, currency, rates)
194
+ return max(total, 0.0)
195
+
196
+
197
+ def extract_pro_choice(product_payload: Dict, base_price: float, currency: str) -> Optional[Tuple[float, str, str]]:
198
+ product = product_payload.get("product") or {}
199
+ options = product.get("options") or []
200
+ rates = _build_rate_map(product)
201
+ candidates: List[Tuple[float, str, str]] = []
202
+
203
+ visible_options = []
204
+ for option in options:
205
+ variants = [v for v in (option.get("variants") or []) if int(v.get("visible", 1) or 0) == 1]
206
+ if variants:
207
+ visible_options.append((option, variants))
208
+
209
+ def default_variant(option: Dict, variants: List[Dict]) -> Dict:
210
+ d = _default_variant(option)
211
+ if d and int(d.get("visible", 1) or 0) == 1:
212
+ return d
213
+ return variants[0]
214
+
215
+ # Gather all PRO-capable variants from all selectable options.
216
+ pro_variants: List[Tuple[Dict, Dict]] = []
217
+ for option, variants in visible_options:
218
+ option_text = f"{option.get('label','')} {option.get('name','')}".lower()
219
+ for variant in variants:
220
+ text = clean_text(str(variant.get("text", "")))
221
+ if not text:
222
+ continue
223
+ 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
241
+ 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,
255
+ )
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))
278
+
279
+ return min(candidates, key=lambda x: x[0])
280
+
281
+
282
+ def classify_offer(
283
+ details: Dict,
284
+ fallback_title: str,
285
+ base_price: float,
286
+ currency: str,
287
+ ) -> Optional[Dict]:
288
+ if int(details.get("retval", -1)) != 0:
289
+ return None
290
+
291
+ product = details.get("product") or {}
292
+ if not product:
293
+ return None
294
+
295
+ # Skip suspended/hidden/outdated goods.
296
+ if str(product.get("is_available", 1)).lower() in {"0", "false"}:
297
+ return None
298
+
299
+ 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:
302
+ 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):
310
+ return None
311
+ return {
312
+ "price_value": displayed_price,
313
+ "pro_choice_text": pro_choice_text,
314
+ "title": title,
315
+ "duration": parsed_duration,
316
+ }
317
+
318
+
319
+ def search_all_products(
320
+ search_url: str,
321
+ lang: str,
322
+ currency: str,
323
+ per_page: int,
324
+ max_items: int,
325
+ sort_by: str,
326
+ max_pages: int,
327
+ ) -> List[Dict]:
328
+ query = parse_search_query(search_url)
329
+ rows = []
330
+ page = 1
331
+ product_cache: Dict[int, Optional[Dict]] = {}
332
+ seller_cache: Dict[int, Dict[str, int]] = {}
333
+ api_sort = normalize_search_sort(sort_by)
334
+ warned_sort_fallback = False
335
+
336
+ while len(rows) < max_items and page <= max_pages:
337
+ api_url = build_search_url(query, page, per_page, currency, lang, api_sort)
338
+ try:
339
+ payload = fetch_json(api_url)
340
+ except HTTPError as e:
341
+ if e.code == 400 and api_sort != "popular":
342
+ api_sort = "popular"
343
+ if not warned_sort_fallback:
344
+ print(
345
+ f"Warning: API sort '{sort_by}' is unsupported, using 'popular' and local cost sort.",
346
+ file=sys.stderr,
347
+ )
348
+ warned_sort_fallback = True
349
+ continue
350
+ raise
351
+ content = payload.get("content") or {}
352
+ items = content.get("items") or []
353
+ if not items:
354
+ break
355
+
356
+ for item in items:
357
+ name = pick_name(item.get("name") or [], lang)
358
+ pid = item.get("product_id")
359
+ seller_id = int(item.get("seller_id") or 0)
360
+ base_price = float(item.get("price", 0))
361
+ displayed_price = base_price
362
+ pro_choice_text = ""
363
+ duration = extract_duration(name)
364
+ seller_reviews = {"total": 0, "good": 0, "bad": 0}
365
+
366
+ if pid not in product_cache:
367
+ product_cache[pid] = None
368
+ try:
369
+ details = fetch_json(build_product_data_url(int(pid), currency, lang), timeout=12)
370
+ product_cache[pid] = classify_offer(details, name, base_price, currency)
371
+ except Exception:
372
+ product_cache[pid] = None
373
+
374
+ offer = product_cache[pid]
375
+ if not offer:
376
+ continue
377
+ displayed_price = float(offer["price_value"])
378
+ pro_choice_text = str(offer["pro_choice_text"])
379
+ name = str(offer["title"])
380
+ duration = str(offer.get("duration") or "") or extract_duration(pro_choice_text) or extract_duration(name) or duration
381
+
382
+ if seller_id > 0:
383
+ if seller_id not in seller_cache:
384
+ seller_cache[seller_id] = {"total": 0, "good": 0, "bad": 0}
385
+ try:
386
+ reviews = fetch_json(build_reviews_url(seller_id, lang), timeout=10)
387
+ seller_cache[seller_id] = {
388
+ "total": int(reviews.get("totalItems", 0) or 0),
389
+ "good": int(reviews.get("totalGood", 0) or 0),
390
+ "bad": int(reviews.get("totalBad", 0) or 0),
391
+ }
392
+ except Exception:
393
+ seller_cache[seller_id] = {"total": 0, "good": 0, "bad": 0}
394
+ seller_reviews = seller_cache[seller_id]
395
+
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)
408
+ if len(rows) >= max_items:
409
+ break
410
+
411
+ if not content.get("has_next_page"):
412
+ break
413
+ page += 1
414
+
415
+ return rows
416
+
417
+
418
+ def render_tui(rows: List[Dict], title: str) -> None:
419
+ try:
420
+ from rich.console import Console
421
+ from rich.table import Table
422
+ except ImportError:
423
+ raise RuntimeError("rich is not installed. Install with: pip install rich")
424
+
425
+ table = Table(title=title, show_lines=False)
426
+ table.add_column("#", justify="right")
427
+ table.add_column("Cost", no_wrap=True)
428
+ table.add_column("Pro Length", no_wrap=True)
429
+ table.add_column("Seller", no_wrap=True)
430
+ table.add_column("Seller Reviews", justify="right", no_wrap=True)
431
+ table.add_column("Good/Bad", justify="right", no_wrap=True)
432
+ table.add_column("Ad", overflow="fold")
433
+ table.add_column("Link", no_wrap=True)
434
+
435
+ for idx, row in enumerate(rows, start=1):
436
+ ad_title = row["title"]
437
+ if row.get("pro_choice"):
438
+ ad_title = f"{ad_title} | PRO: {row['pro_choice']}"
439
+ ad_text = ad_title
440
+ link_text = f"[link={row['link']}]open[/link]"
441
+ table.add_row(
442
+ str(idx),
443
+ row["price"],
444
+ row["duration"] or "-",
445
+ row["seller"],
446
+ str(row["seller_reviews"]),
447
+ row["seller_good_bad"],
448
+ ad_text,
449
+ link_text,
450
+ )
451
+
452
+ Console().print(table)
453
+
454
+
455
+ def render_html_report(rows: List[Dict], title: str, output_path: str) -> None:
456
+ style = """
457
+ :root {
458
+ --bg: #0b1020;
459
+ --panel: #101a33;
460
+ --line: #25345d;
461
+ --text: #e8edf9;
462
+ --muted: #9eb0da;
463
+ --accent: #5ca8ff;
464
+ }
465
+ * { box-sizing: border-box; }
466
+ body {
467
+ margin: 0;
468
+ font-family: "Segoe UI", Arial, sans-serif;
469
+ background: radial-gradient(circle at top, #14254a 0%, var(--bg) 45%);
470
+ color: var(--text);
471
+ }
472
+ .wrap { max-width: 1400px; margin: 28px auto; padding: 0 16px; }
473
+ .card {
474
+ background: linear-gradient(180deg, #132247 0%, var(--panel) 100%);
475
+ border: 1px solid var(--line);
476
+ border-radius: 14px;
477
+ padding: 18px;
478
+ box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
479
+ }
480
+ h1 { margin: 0 0 6px; font-size: 22px; }
481
+ .meta { color: var(--muted); margin-bottom: 14px; }
482
+ .controls {
483
+ display: flex;
484
+ gap: 12px;
485
+ align-items: center;
486
+ margin-bottom: 12px;
487
+ color: var(--muted);
488
+ flex-wrap: wrap;
489
+ }
490
+ select {
491
+ background: #0f1b38;
492
+ color: var(--text);
493
+ border: 1px solid var(--line);
494
+ border-radius: 8px;
495
+ padding: 7px 10px;
496
+ }
497
+ table { width: 100%; border-collapse: collapse; }
498
+ th, td {
499
+ padding: 10px 8px;
500
+ border-bottom: 1px solid #223056;
501
+ vertical-align: top;
502
+ }
503
+ th {
504
+ color: #c9d7f5;
505
+ text-align: left;
506
+ position: sticky;
507
+ top: 0;
508
+ background: #122042;
509
+ cursor: pointer;
510
+ user-select: none;
511
+ white-space: nowrap;
512
+ }
513
+ tr:hover td { background: rgba(92, 168, 255, 0.08); }
514
+ .num { text-align: right; white-space: nowrap; }
515
+ a { color: var(--accent); text-decoration: none; }
516
+ a:hover { text-decoration: underline; }
517
+ .small { color: var(--muted); font-size: 12px; }
518
+ """
519
+
520
+ rows_html = []
521
+ for idx, row in enumerate(rows, start=1):
522
+ ad_title = row["title"]
523
+ if row.get("pro_choice"):
524
+ ad_title = f"{ad_title} | PRO: {row['pro_choice']}"
525
+ rows_html.append(
526
+ (
527
+ f"<tr data-cost='{float(row.get('price_value', 0.0))}' "
528
+ f"data-reviews='{int(row.get('seller_reviews', 0))}' "
529
+ f"data-seller='{html.escape(str(row.get('seller', ''))).lower()}'>"
530
+ f"<td class='num'>{idx}</td>"
531
+ f"<td class='num'>{html.escape(str(row['price']))}</td>"
532
+ f"<td>{html.escape(str(row['duration'] or '-'))}</td>"
533
+ f"<td>{html.escape(str(row['seller']))}</td>"
534
+ f"<td class='num'>{int(row.get('seller_reviews', 0)):,}</td>"
535
+ f"<td class='num'>{html.escape(str(row['seller_good_bad']))}</td>"
536
+ f"<td><a href='{html.escape(str(row['link']))}' target='_blank' rel='noreferrer'>{html.escape(ad_title)}</a></td>"
537
+ f"<td><a href='{html.escape(str(row['link']))}' target='_blank' rel='noreferrer'>open</a></td>"
538
+ "</tr>"
539
+ )
540
+ )
541
+
542
+ html_doc = f"""<!doctype html>
543
+ <html lang="en">
544
+ <head>
545
+ <meta charset="utf-8" />
546
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
547
+ <title>{html.escape(title)}</title>
548
+ <style>{style}</style>
549
+ </head>
550
+ <body>
551
+ <div class="wrap">
552
+ <div class="card">
553
+ <h1>{html.escape(title)}</h1>
554
+ <div class="meta">Rows: {len(rows)} · Click headers to sort</div>
555
+ <div class="controls">
556
+ <label for="sort">Sort:</label>
557
+ <select id="sort">
558
+ <option value="cost:asc">Cost (cheap → expensive)</option>
559
+ <option value="cost:desc">Cost (expensive → cheap)</option>
560
+ <option value="reviews:desc">Seller reviews (high → low)</option>
561
+ <option value="seller:asc">Seller (A → Z)</option>
562
+ </select>
563
+ </div>
564
+ <table id="report">
565
+ <thead>
566
+ <tr>
567
+ <th data-key="idx">#</th>
568
+ <th data-key="cost">Cost</th>
569
+ <th data-key="duration">Pro Length</th>
570
+ <th data-key="seller">Seller</th>
571
+ <th data-key="reviews">Seller Reviews</th>
572
+ <th data-key="goodbad">Good/Bad</th>
573
+ <th data-key="ad">Ad</th>
574
+ <th data-key="link">Link</th>
575
+ </tr>
576
+ </thead>
577
+ <tbody>
578
+ {''.join(rows_html)}
579
+ </tbody>
580
+ </table>
581
+ <p class="small">All links open in a new tab.</p>
582
+ </div>
583
+ </div>
584
+ <script>
585
+ (() => {{
586
+ const table = document.getElementById('report');
587
+ const body = table.querySelector('tbody');
588
+ const sortSelect = document.getElementById('sort');
589
+ const getText = (row, i) => row.children[i]?.innerText?.toLowerCase() ?? '';
590
+ const keyToIdx = {{ idx: 0, cost: 1, duration: 2, seller: 3, reviews: 4, goodbad: 5, ad: 6, link: 7 }};
591
+
592
+ function sortRows(key, dir) {{
593
+ const rows = Array.from(body.querySelectorAll('tr'));
594
+ rows.sort((a, b) => {{
595
+ if (key === 'cost') {{
596
+ const av = Number(a.dataset.cost || 0);
597
+ const bv = Number(b.dataset.cost || 0);
598
+ return dir === 'asc' ? av - bv : bv - av;
599
+ }}
600
+ if (key === 'reviews') {{
601
+ const av = Number(a.dataset.reviews || 0);
602
+ const bv = Number(b.dataset.reviews || 0);
603
+ return dir === 'asc' ? av - bv : bv - av;
604
+ }}
605
+ if (key === 'idx') {{
606
+ const av = Number(getText(a, 0));
607
+ const bv = Number(getText(b, 0));
608
+ return dir === 'asc' ? av - bv : bv - av;
609
+ }}
610
+ const i = keyToIdx[key] ?? 0;
611
+ const av = getText(a, i);
612
+ const bv = getText(b, i);
613
+ return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
614
+ }});
615
+ rows.forEach((r, i) => {{
616
+ r.children[0].innerText = String(i + 1);
617
+ body.appendChild(r);
618
+ }});
619
+ }}
620
+
621
+ sortSelect.addEventListener('change', () => {{
622
+ const [key, dir] = sortSelect.value.split(':');
623
+ sortRows(key, dir);
624
+ }});
625
+
626
+ let state = {{ key: 'cost', dir: 'asc' }};
627
+ table.querySelectorAll('th').forEach((th) => {{
628
+ th.addEventListener('click', () => {{
629
+ const key = th.dataset.key;
630
+ const dir = state.key === key && state.dir === 'asc' ? 'desc' : 'asc';
631
+ state = {{ key, dir }};
632
+ sortRows(key, dir);
633
+ }});
634
+ }});
635
+
636
+ sortRows('cost', 'asc');
637
+ }})();
638
+ </script>
639
+ </body>
640
+ </html>"""
641
+ with open(output_path, "w", encoding="utf-8") as f:
642
+ f.write(html_doc)
643
+
644
+
645
+ def main() -> int:
646
+ p = argparse.ArgumentParser(
647
+ description="Scrape Plati search results and render a TUI table."
648
+ )
649
+ p.add_argument("url", help="Search URL, e.g. https://plati.market/search/chatgpt")
650
+ p.add_argument("--lang", default="ru-RU", help="Locale for names and API query")
651
+ p.add_argument("--curr", default="RUB", help="Currency (RUB, USD, EUR, ...)")
652
+ p.add_argument("--per-page", type=int, default=30, help="Items per API page")
653
+ p.add_argument("--max-items", type=int, default=120, help="Maximum rows to load")
654
+ p.add_argument("--max-pages", type=int, default=12, help="Maximum search pages to scan")
655
+ p.add_argument(
656
+ "--sort-by",
657
+ default="popular",
658
+ choices=["popular", "price_asc", "price_desc", "new"],
659
+ help="Search sort order",
660
+ )
661
+ p.add_argument(
662
+ "--cost-sort",
663
+ default="asc",
664
+ choices=["asc", "desc", "none"],
665
+ help="Local sorting by rendered cost (asc = cheap to expensive)",
666
+ )
667
+ p.add_argument(
668
+ "--format",
669
+ default="html",
670
+ choices=["html", "tui"],
671
+ help="Output format",
672
+ )
673
+ p.add_argument(
674
+ "--out",
675
+ default="plati_report.html",
676
+ help="HTML output file path (used when --format html)",
677
+ )
678
+ args = p.parse_args()
679
+
680
+ try:
681
+ rows = search_all_products(
682
+ search_url=args.url,
683
+ lang=args.lang,
684
+ currency=args.curr,
685
+ per_page=args.per_page,
686
+ max_items=args.max_items,
687
+ sort_by=args.sort_by,
688
+ max_pages=args.max_pages,
689
+ )
690
+ if args.cost_sort == "asc":
691
+ rows.sort(key=lambda r: float(r.get("price_value", 0.0)))
692
+ elif args.cost_sort == "desc":
693
+ rows.sort(key=lambda r: float(r.get("price_value", 0.0)), reverse=True)
694
+ title = f"Plati search: {args.url}"
695
+ if args.format == "html":
696
+ render_html_report(rows, title=title, output_path=args.out)
697
+ print(f"HTML report saved to: {args.out}")
698
+ else:
699
+ render_tui(rows, title=title)
700
+ except Exception as e:
701
+ print(f"Error: {e}", file=sys.stderr)
702
+ return 1
703
+
704
+ return 0
705
+
706
+
707
+ if __name__ == "__main__":
708
+ raise SystemExit(main())