funpay_client/parsing/
orders.rs

1use 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}