funpay_client/parsing/
orders.rs1use crate::error::FunPayError;
2use crate::models::enums::OrderStatus;
3use crate::models::ids::{ChatId, OrderId};
4use crate::models::{Order, OrderShortcut, Review, Subcategory};
5use crate::parsing::locales;
6use regex::Regex;
7use scraper::{Html, Selector};
8use std::collections::HashMap;
9
10pub fn parse_orders_list(html: &str, my_id: i64) -> Result<Vec<OrderShortcut>, FunPayError> {
11 let doc = Html::parse_document(html);
12
13 let sel_user = Selector::parse("div.user-link-name").unwrap();
14 if doc.select(&sel_user).next().is_none() {
15 return Err(FunPayError::Unauthorized);
16 }
17
18 let sel_item = Selector::parse("a.tc-item").unwrap();
19 let sel_order = Selector::parse("div.tc-order").unwrap();
20 let sel_desc = Selector::parse("div.order-desc").unwrap();
21 let sel_price = Selector::parse("div.tc-price").unwrap();
22 let sel_buyer = Selector::parse("div.media-user-name span").unwrap();
23 let sel_subcat = Selector::parse("div.text-muted").unwrap();
24 let sel_subcat_link = Selector::parse("div.text-muted a").unwrap();
25 let sel_date = Selector::parse("div.tc-date-time").unwrap();
26 let sel_div = Selector::parse("div").unwrap();
27
28 let re_subcat =
29 Regex::new(r"/(?:chips|lots|market|goods|game|category|subcategory)/(\d+)/?").unwrap();
30 let re_amount = Regex::new(r"(?i)(\d+)\s*(шт|pcs|pieces|ед)\.?").unwrap();
31
32 let mut out = Vec::new();
33
34 for a in doc.select(&sel_item) {
35 let class_list: Vec<String> = a.value().classes().map(|s| s.to_string()).collect();
36 let status = if class_list.iter().any(|c| c == "warning") {
37 OrderStatus::Refunded
38 } else if class_list.iter().any(|c| c == "info") {
39 OrderStatus::Paid
40 } else {
41 OrderStatus::Closed
42 };
43
44 let Some(order_div) = a.select(&sel_order).next() else {
45 continue;
46 };
47
48 let mut id_text = order_div.text().collect::<String>();
49 id_text = id_text.trim().to_string();
50 let id = id_text.strip_prefix('#').unwrap_or(&id_text).to_string();
51
52 let description = a
53 .select(&sel_desc)
54 .next()
55 .and_then(|d| {
56 d.select(&sel_div)
57 .next()
58 .map(|n| n.text().collect::<String>())
59 })
60 .unwrap_or_default()
61 .trim()
62 .to_string();
63
64 let price_text_raw = a
65 .select(&sel_price)
66 .next()
67 .map(|n| n.text().collect::<String>())
68 .unwrap_or_default();
69 let price_text = price_text_raw.replace('\u{00A0}', " ").trim().to_string();
70
71 let (price_val, currency) = if let Some((p, cur)) = price_text.rsplit_once(' ') {
72 let pv = p.replace(' ', "");
73 let parsed = pv.parse::<f64>().unwrap_or(0.0);
74 (parsed, cur.to_string())
75 } else {
76 (0.0, String::new())
77 };
78
79 let buyer_span = a.select(&sel_buyer).next();
80 let buyer_username = buyer_span
81 .as_ref()
82 .map(|n| n.text().collect::<String>().trim().to_string())
83 .unwrap_or_default();
84
85 let buyer_id = buyer_span
86 .and_then(|n| n.value().attr("data-href"))
87 .and_then(|v| v.split("/users/").nth(1))
88 .and_then(|tail| tail.trim_end_matches('/').parse::<i64>().ok())
89 .unwrap_or(0);
90
91 let (id1, id2) = (my_id.min(buyer_id), my_id.max(buyer_id));
92 let chat_id = ChatId::from(format!("users-{id1}-{id2}"));
93
94 let subcategory_name = a
95 .select(&sel_subcat)
96 .next()
97 .map(|n| n.text().collect::<String>().trim().to_string())
98 .unwrap_or_default();
99
100 let subcategory_id = a
101 .select(&sel_subcat_link)
102 .next()
103 .and_then(|lnk| lnk.value().attr("href"))
104 .and_then(|href| {
105 re_subcat
106 .captures(href)
107 .and_then(|c| c.get(1))
108 .and_then(|m| m.as_str().parse::<i64>().ok())
109 });
110
111 let subcategory = Subcategory {
112 id: subcategory_id,
113 name: subcategory_name,
114 };
115
116 let date_text = a
117 .select(&sel_date)
118 .next()
119 .map(|n| n.text().collect::<String>().trim().to_string())
120 .unwrap_or_default();
121
122 let amount = re_amount
123 .captures(&description)
124 .and_then(|caps| {
125 caps.get(1)
126 .and_then(|m| m.as_str().replace(' ', "").parse::<i32>().ok())
127 })
128 .unwrap_or(1);
129
130 out.push(OrderShortcut {
131 id: OrderId::from(id),
132 description,
133 price: price_val,
134 currency,
135 buyer_username,
136 buyer_id,
137 chat_id,
138 status,
139 date_text,
140 subcategory,
141 amount,
142 });
143 }
144
145 Ok(out)
146}
147
148pub fn parse_order_secrets(doc: &Html) -> Vec<String> {
149 let sel_param = Selector::parse("div.param-item").unwrap();
150 let sel_h5 = Selector::parse("h5").unwrap();
151 let sel_secret = Selector::parse("span.secret-placeholder").unwrap();
152
153 let mut order_secrets = Vec::new();
154 for p in doc.select(&sel_param) {
155 let Some(header) = p.select(&sel_h5).next() else {
156 continue;
157 };
158 let h_text = header.text().collect::<String>();
159 let h = h_text.trim();
160 if locales::matches_any(h, locales::PAID_PRODUCT) {
161 for s in p.select(&sel_secret) {
162 let t = s.text().collect::<String>().trim().to_string();
163 if !t.is_empty() {
164 order_secrets.push(t);
165 }
166 }
167 }
168 }
169 order_secrets
170}
171
172pub fn parse_order_page(html: &str, order_id: &str) -> Result<Order, FunPayError> {
173 let doc = Html::parse_document(html);
174 let sel_user = Selector::parse("div.user-link-name").unwrap();
175 if doc.select(&sel_user).next().is_none() {
176 return Err(FunPayError::Unauthorized);
177 }
178
179 let re_category = Regex::new(r"/(?:chips|lots)/(\d+)/?").unwrap();
180 let re_users = Regex::new(r"/users/(\d+)/").unwrap();
181 let re_chat = Regex::new(r"/chat/(\d+)/").unwrap();
182
183 let status = {
184 let sel_warn = Selector::parse("span.text-warning").unwrap();
185 let sel_succ = Selector::parse("span.text-success").unwrap();
186 let refunded = doc
187 .select(&sel_warn)
188 .next()
189 .map(|n| n.text().collect::<String>())
190 .map(|t| locales::matches_any(t.trim(), locales::REFUND))
191 .unwrap_or(false);
192 if refunded {
193 OrderStatus::Refunded
194 } else {
195 let closed = doc
196 .select(&sel_succ)
197 .next()
198 .map(|n| n.text().collect::<String>())
199 .map(|t| locales::matches_any(t.trim(), locales::CLOSED))
200 .unwrap_or(false);
201 if closed {
202 OrderStatus::Closed
203 } else {
204 OrderStatus::Paid
205 }
206 }
207 };
208
209 let sel_param = Selector::parse("div.param-item").unwrap();
210 let sel_h5 = Selector::parse("h5").unwrap();
211 let sel_div = Selector::parse("div").unwrap();
212
213 let mut short_description: Option<String> = None;
214 let mut full_description: Option<String> = None;
215 let mut lot_params: Vec<(String, String)> = Vec::new();
216 let buyer_params: HashMap<String, String> = HashMap::new();
217 let mut amount: Option<i32> = None;
218 let mut subcategory: Option<Subcategory> = None;
219
220 for p in doc.select(&sel_param) {
221 let Some(header) = p.select(&sel_h5).next() else {
222 continue;
223 };
224 let h_text = header.text().collect::<String>();
225 let h = h_text.trim();
226 if locales::matches_any(h, locales::SHORT_DESCRIPTION) {
227 if let Some(content) = p.select(&sel_div).next() {
228 short_description = Some(content.text().collect::<String>().trim().to_string());
229 }
230 } else if locales::matches_any(h, locales::FULL_DESCRIPTION) {
231 if let Some(content) = p.select(&sel_div).next() {
232 full_description = Some(content.text().collect::<String>().trim().to_string());
233 }
234 } else if locales::matches_any(h, locales::CATEGORY) {
235 let sel_a = Selector::parse("a").unwrap();
236 if let Some(a) = p.select(&sel_a).next() {
237 let href = a.value().attr("href").unwrap_or("");
238 let name = a.text().collect::<String>().trim().to_string();
239 let id = re_category
240 .captures(href)
241 .and_then(|c| c.get(1))
242 .and_then(|m| m.as_str().parse::<i64>().ok());
243 if let Some(sid) = id {
244 subcategory = Some(Subcategory {
245 id: Some(sid),
246 name,
247 });
248 }
249 }
250 } else if locales::matches_any(h, locales::AMOUNT) {
251 let content = p.select(&sel_div).next();
252 if let Some(c) = content {
253 let a_txt = c.text().collect::<String>().trim().to_string();
254 if let Ok(a) = a_txt.parse::<i32>() {
255 amount = Some(a);
256 }
257 }
258 } else if !locales::matches_any(h, locales::PAID_PRODUCT) {
259 let content_div = p.select(&sel_div).next();
260 if let Some(content) = content_div {
261 let content_text = content.text().collect::<String>().trim().to_string();
262 if !content_text.is_empty() {
263 lot_params.push((h.to_string(), content_text));
264 }
265 }
266 }
267 }
268
269 let order_secrets = parse_order_secrets(&doc);
270
271 let sel_order_buyer = Selector::parse(".order-buyer").unwrap();
272 let sel_order_sum = Selector::parse(".order-sum").unwrap();
273
274 let buyer_info = doc.select(&sel_order_buyer).next();
275 let sum_info = doc.select(&sel_order_sum).next();
276
277 let (buyer_id, buyer_username) = buyer_info
278 .and_then(|buyer| buyer.select(&Selector::parse("a").unwrap()).next())
279 .map(|link| {
280 let username = link.text().collect::<String>().trim().to_string();
281 let id = link
282 .value()
283 .attr("href")
284 .and_then(|href| re_users.captures(href))
285 .and_then(|captures| captures.get(1))
286 .and_then(|id_str| id_str.as_str().parse::<i64>().ok());
287 (id, Some(username))
288 })
289 .unwrap_or((None, None));
290
291 let (sum_val, currency) = if let Some(sum) = sum_info {
292 let sum_text = sum.text().collect::<String>();
293 let re = Regex::new(r"([\d.,]+)\s*([A-Za-zА-Яа-я₽$€£¥₴]+)").unwrap();
294 if let Some(captures) = re.captures(&sum_text) {
295 let amount_str = captures.get(1).map(|m| m.as_str()).unwrap_or("");
296 let curr_str = captures.get(2).map(|m| m.as_str()).unwrap_or("");
297 let amount_parsed = amount_str.replace(',', ".").parse::<f64>().ok();
298 (
299 amount_parsed,
300 if curr_str.is_empty() {
301 None
302 } else {
303 Some(curr_str.to_string())
304 },
305 )
306 } else {
307 (None, None)
308 }
309 } else {
310 (None, None)
311 };
312
313 let chat_id = {
314 let sel_chat = Selector::parse("a[href*='/chat/']").unwrap();
315 doc.select(&sel_chat).next().and_then(|a| {
316 a.value().attr("href").and_then(|href| {
317 re_chat.captures(href).and_then(|captures| {
318 captures
319 .get(1)
320 .map(|id| ChatId::from(id.as_str().to_string()))
321 })
322 })
323 })
324 };
325
326 let review = {
327 let sel_review = Selector::parse(".review-item").unwrap();
328 doc.select(&sel_review).next().map(|r| {
329 let rating_sel = Selector::parse(".rating-mini .fas.fa-star").unwrap();
330 let stars = Some(r.select(&rating_sel).count() as i32);
331 let text_sel = Selector::parse(".review-text").unwrap();
332 let text = r
333 .select(&text_sel)
334 .next()
335 .map(|t| t.text().collect::<String>().trim().to_string());
336 Review {
337 stars,
338 text,
339 reply: None,
340 anonymous: false,
341 html: r.text().collect::<String>(),
342 hidden: false,
343 order_id: Some(OrderId::from(order_id.to_string())),
344 author: None,
345 author_id: None,
346 by_bot: false,
347 reply_by_bot: false,
348 }
349 })
350 };
351
352 Ok(Order {
353 id: OrderId::from(order_id.to_string()),
354 status,
355 lot_params,
356 buyer_params,
357 short_description,
358 full_description,
359 subcategory,
360 amount: amount.unwrap_or(0),
361 sum: sum_val.unwrap_or(0.0),
362 currency: currency.unwrap_or_else(|| String::from("RUB")),
363 buyer_id: buyer_id.unwrap_or(0),
364 buyer_username: buyer_username.unwrap_or_default(),
365 seller_id: 0,
366 seller_username: String::new(),
367 chat_id: chat_id.unwrap_or_else(|| ChatId::from(String::from("0"))),
368 html: html.to_string(),
369 review,
370 order_secrets,
371 })
372}