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 +48 -0
- package/bin/plati-mcp-server.js +35 -0
- package/mcp_server.py +191 -0
- package/package.json +29 -0
- package/plati_scrape.py +708 -0
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
|
+
}
|
package/plati_scrape.py
ADDED
|
@@ -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())
|