funpay_client/parsing/
offers.rs

1use crate::models::{
2    MarketOffer, Offer, OfferCustomField, OfferEditParams, OfferFieldOption, OfferFieldType,
3    OfferFullParams,
4};
5use crate::parsing::{
6    extract_checkbox_value, extract_field_value, extract_input_value, extract_textarea_value,
7};
8use regex::Regex;
9use scraper::{Html, Selector};
10
11pub fn parse_my_offers(html: &str, node_id: i64) -> Vec<Offer> {
12    let doc = Html::parse_document(html);
13    let sel_item = Selector::parse("a.tc-item[data-offer]").unwrap();
14    let sel_desc = Selector::parse("div.tc-desc-text").unwrap();
15    let sel_price = Selector::parse("div.tc-price").unwrap();
16    let sel_unit = Selector::parse("span.unit").unwrap();
17
18    let mut offers = Vec::new();
19
20    for item in doc.select(&sel_item) {
21        let offer_id = item
22            .value()
23            .attr("data-offer")
24            .and_then(|s| s.parse::<i64>().ok())
25            .unwrap_or(0);
26
27        if offer_id == 0 {
28            continue;
29        }
30
31        let description = item
32            .select(&sel_desc)
33            .next()
34            .map(|el| el.text().collect::<String>().trim().to_string())
35            .unwrap_or_default();
36
37        let price_el = item.select(&sel_price).next();
38        let price = price_el
39            .and_then(|el| el.value().attr("data-s"))
40            .and_then(|s| s.parse::<f64>().ok())
41            .unwrap_or(0.0);
42
43        let currency = price_el
44            .and_then(|el| el.select(&sel_unit).next())
45            .map(|el| el.text().collect::<String>().trim().to_string())
46            .unwrap_or_else(|| "₽".to_string());
47
48        let active = !item.value().classes().any(|c| c == "warning");
49
50        offers.push(Offer {
51            id: offer_id,
52            node_id,
53            description,
54            price,
55            currency,
56            active,
57        });
58    }
59
60    offers
61}
62
63pub fn parse_market_offers(html: &str, node_id: i64) -> Vec<MarketOffer> {
64    let doc = Html::parse_document(html);
65    let sel_item = Selector::parse("a.tc-item").unwrap();
66    let sel_desc = Selector::parse("div.tc-desc-text").unwrap();
67    let sel_price = Selector::parse("div.tc-price").unwrap();
68    let sel_unit = Selector::parse("span.unit").unwrap();
69    let sel_seller = Selector::parse("span.pseudo-a[data-href]").unwrap();
70    let sel_reviews = Selector::parse("div.media-user-reviews").unwrap();
71    let sel_rating_count = Selector::parse("span.rating-mini-count").unwrap();
72    let sel_rating_stars = Selector::parse("div.rating-stars").unwrap();
73
74    let re_offer_id = Regex::new(r"[?&]id=(\d+)").unwrap();
75    let re_user_id = Regex::new(r"/users/(\d+)/?").unwrap();
76    let re_reviews_text = Regex::new(r"(\d+)").unwrap();
77    let re_rating = Regex::new(r"rating-(\d+(?:\.\d+)?)").unwrap();
78
79    let mut offers = Vec::new();
80
81    for item in doc.select(&sel_item) {
82        let href = item.value().attr("href").unwrap_or("");
83        let offer_id = re_offer_id
84            .captures(href)
85            .and_then(|c| c.get(1))
86            .and_then(|m| m.as_str().parse::<i64>().ok())
87            .unwrap_or(0);
88
89        if offer_id == 0 {
90            continue;
91        }
92
93        let description = item
94            .select(&sel_desc)
95            .next()
96            .map(|el| el.text().collect::<String>().trim().to_string())
97            .unwrap_or_default();
98
99        let price_el = item.select(&sel_price).next();
100        let price = price_el
101            .and_then(|el| el.value().attr("data-s"))
102            .and_then(|s| s.parse::<f64>().ok())
103            .unwrap_or(0.0);
104
105        let currency = price_el
106            .and_then(|el| el.select(&sel_unit).next())
107            .map(|el| el.text().collect::<String>().trim().to_string())
108            .unwrap_or_else(|| "₽".to_string());
109
110        let seller_el = item.select(&sel_seller).next();
111        let seller_name = seller_el
112            .map(|el| el.text().collect::<String>().trim().to_string())
113            .unwrap_or_default();
114
115        let seller_id = seller_el
116            .and_then(|el| el.value().attr("data-href"))
117            .and_then(|href| {
118                re_user_id
119                    .captures(href)
120                    .and_then(|c| c.get(1))
121                    .and_then(|m| m.as_str().parse::<i64>().ok())
122            })
123            .unwrap_or(0);
124
125        let seller_online = item.value().attr("data-online") == Some("1");
126        let is_promo = item.value().classes().any(|c| c == "offer-promo");
127
128        let seller_reviews = item
129            .select(&sel_reviews)
130            .next()
131            .and_then(|reviews_el| {
132                if let Some(count_el) = reviews_el.select(&sel_rating_count).next() {
133                    count_el
134                        .text()
135                        .collect::<String>()
136                        .trim()
137                        .parse::<u32>()
138                        .ok()
139                } else {
140                    let text = reviews_el.text().collect::<String>();
141                    re_reviews_text
142                        .captures(&text)
143                        .and_then(|c| c.get(1))
144                        .and_then(|m| m.as_str().parse::<u32>().ok())
145                }
146            })
147            .unwrap_or(0);
148
149        let seller_rating = item.select(&sel_reviews).next().and_then(|reviews_el| {
150            reviews_el
151                .select(&sel_rating_stars)
152                .next()
153                .and_then(|rating_el| {
154                    rating_el.value().classes().find_map(|class| {
155                        re_rating
156                            .captures(class)
157                            .and_then(|c| c.get(1))
158                            .and_then(|m| m.as_str().parse::<f64>().ok())
159                    })
160                })
161        });
162
163        offers.push(MarketOffer {
164            id: offer_id,
165            node_id,
166            description,
167            price,
168            currency,
169            seller_id,
170            seller_name,
171            seller_online,
172            seller_rating,
173            seller_reviews,
174            is_promo,
175        });
176    }
177
178    offers
179}
180
181pub fn parse_offer_edit_params(html: &str) -> OfferEditParams {
182    let doc = Html::parse_document(html);
183
184    OfferEditParams {
185        quantity: Some(extract_field_value(&doc, "fields[quantity]")),
186        quantity2: Some(extract_field_value(&doc, "fields[quantity2]")),
187        method: Some(extract_field_value(&doc, "fields[method]")),
188        offer_type: Some(extract_field_value(&doc, "fields[type]")),
189        server_id: Some(extract_field_value(&doc, "server_id")),
190        desc_ru: Some(extract_textarea_value(&doc, "fields[desc][ru]")),
191        desc_en: Some(extract_textarea_value(&doc, "fields[desc][en]")),
192        payment_msg_ru: Some(extract_textarea_value(&doc, "fields[payment_msg][ru]")),
193        payment_msg_en: Some(extract_textarea_value(&doc, "fields[payment_msg][en]")),
194        summary_ru: Some(extract_input_value(&doc, "fields[summary][ru]")),
195        summary_en: Some(extract_input_value(&doc, "fields[summary][en]")),
196        game: Some(extract_field_value(&doc, "fields[game]")),
197        images: Some(extract_input_value(&doc, "fields[images]")),
198        price: Some(extract_input_value(&doc, "price")),
199        deactivate_after_sale: Some(extract_checkbox_value(&doc, "deactivate_after_sale")),
200        active: Some(extract_checkbox_value(&doc, "active")),
201        location: Some(extract_input_value(&doc, "location")),
202        deleted: None,
203    }
204}
205
206pub fn parse_offer_full_params(html: &str, offer_id: i64, node_id: i64) -> OfferFullParams {
207    let doc = Html::parse_document(html);
208    let mut custom_fields = Vec::new();
209    let sel_form_group = Selector::parse("div.form-group").unwrap();
210    let sel_label = Selector::parse("label").unwrap();
211    let sel_input = Selector::parse("input").unwrap();
212    let sel_textarea = Selector::parse("textarea").unwrap();
213    let sel_select = Selector::parse("select").unwrap();
214    let sel_option = Selector::parse("option").unwrap();
215    let re_field_name = Regex::new(r"fields\[([^\]]+)\]").unwrap();
216
217    for group in doc.select(&sel_form_group) {
218        let label_text = group
219            .select(&sel_label)
220            .next()
221            .map(|l| l.text().collect::<String>().trim().to_string())
222            .unwrap_or_default();
223
224        if let Some(input) = group.select(&sel_input).next() {
225            let name = input.value().attr("name").unwrap_or("");
226            if !name.starts_with("fields[")
227                || name.contains("[desc]")
228                || name.contains("[payment_msg]")
229                || name.contains("[images]")
230            {
231                continue;
232            }
233
234            let input_type = input.value().attr("type").unwrap_or("text");
235            let value = input.value().attr("value").unwrap_or("").to_string();
236
237            let _field_name = re_field_name
238                .captures(name)
239                .and_then(|c| c.get(1))
240                .map(|m| m.as_str().to_string())
241                .unwrap_or_else(|| name.to_string());
242
243            let field_type = match input_type {
244                "checkbox" => OfferFieldType::Checkbox,
245                "hidden" => OfferFieldType::Hidden,
246                _ => OfferFieldType::Text,
247            };
248
249            let actual_value = if field_type == OfferFieldType::Checkbox {
250                if input.value().attr("checked").is_some() {
251                    "true".to_string()
252                } else {
253                    "false".to_string()
254                }
255            } else {
256                value
257            };
258
259            custom_fields.push(OfferCustomField {
260                name: name.to_string(),
261                label: label_text.clone(),
262                field_type,
263                value: actual_value,
264                options: vec![],
265            });
266        } else if let Some(textarea) = group.select(&sel_textarea).next() {
267            let name = textarea.value().attr("name").unwrap_or("");
268            if !name.starts_with("fields[")
269                || name.contains("[desc]")
270                || name.contains("[payment_msg]")
271            {
272                continue;
273            }
274
275            let value = textarea.text().collect::<String>();
276
277            custom_fields.push(OfferCustomField {
278                name: name.to_string(),
279                label: label_text.clone(),
280                field_type: OfferFieldType::Textarea,
281                value,
282                options: vec![],
283            });
284        } else if let Some(select) = group.select(&sel_select).next() {
285            let name = select.value().attr("name").unwrap_or("");
286            if !name.starts_with("fields[") {
287                continue;
288            }
289
290            let mut options = Vec::new();
291            let mut selected_value = String::new();
292
293            for opt in select.select(&sel_option) {
294                let opt_value = opt.value().attr("value").unwrap_or("").to_string();
295                let opt_label = opt.text().collect::<String>().trim().to_string();
296                let is_selected = opt.value().attr("selected").is_some();
297
298                if is_selected {
299                    selected_value = opt_value.clone();
300                }
301
302                options.push(OfferFieldOption {
303                    value: opt_value,
304                    label: opt_label,
305                    selected: is_selected,
306                });
307            }
308
309            custom_fields.push(OfferCustomField {
310                name: name.to_string(),
311                label: label_text.clone(),
312                field_type: OfferFieldType::Select,
313                value: selected_value,
314                options,
315            });
316        }
317    }
318
319    OfferFullParams {
320        offer_id,
321        node_id,
322        quantity: Some(extract_field_value(&doc, "fields[quantity]")).filter(|s| !s.is_empty()),
323        quantity2: Some(extract_field_value(&doc, "fields[quantity2]")).filter(|s| !s.is_empty()),
324        method: Some(extract_field_value(&doc, "fields[method]")).filter(|s| !s.is_empty()),
325        offer_type: Some(extract_field_value(&doc, "fields[type]")).filter(|s| !s.is_empty()),
326        server_id: Some(extract_field_value(&doc, "server_id")).filter(|s| !s.is_empty()),
327        desc_ru: Some(extract_textarea_value(&doc, "fields[desc][ru]")).filter(|s| !s.is_empty()),
328        desc_en: Some(extract_textarea_value(&doc, "fields[desc][en]")).filter(|s| !s.is_empty()),
329        payment_msg_ru: Some(extract_textarea_value(&doc, "fields[payment_msg][ru]"))
330            .filter(|s| !s.is_empty()),
331        payment_msg_en: Some(extract_textarea_value(&doc, "fields[payment_msg][en]"))
332            .filter(|s| !s.is_empty()),
333        images: Some(extract_input_value(&doc, "fields[images]")).filter(|s| !s.is_empty()),
334        price: Some(extract_input_value(&doc, "price")).filter(|s| !s.is_empty()),
335        deactivate_after_sale: extract_checkbox_value(&doc, "deactivate_after_sale"),
336        active: extract_checkbox_value(&doc, "active"),
337        location: Some(extract_input_value(&doc, "location")).filter(|s| !s.is_empty()),
338        custom_fields,
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    const TEST_HTML: &str = r#"
347<a href="https://funpay.com/lots/offer?id=58789647" class="tc-item offer-promo offer-promoted" data-online="1" data-user="4029757" data-f-quantity="13 звёзд" data-f-method="подарком">
348<div class="tc-desc">
349<div class="tc-desc-text">13 звёзд, Подарком</div>
350</div>
351<div class="tc-user">
352<div class="media media-user online style-circle">
353<div class="media-left">
354<div class="avatar-photo pseudo-a" tabindex="0" data-href="https://funpay.com/users/4029757/" style="background-image: url(https://sfunpay.com/s/avatar/cp/wy/cpwyafyka0rqnbjemxc4.jpg);"></div>
355</div>
356<div class="media-body">
357<div class="media-user-name">
358<span class="pseudo-a" tabindex="0" data-href="https://funpay.com/users/4029757/">Ded777Veka</span>
359</div>
360<div class="media-user-reviews">
361<div class="rating-stars rating-5"><i class="fas"></i><i class="fas"></i><i class="fas"></i><i class="fas"></i><i class="fas"></i></div><span class="rating-mini-count">220</span>
362</div>
363<div class="media-user-info">на сайте 4 года</div>
364</div>
365</div>
366</div><div class="tc-price" data-s="20.89613">
367<div>20.90 <span class="unit">₽</span></div>
368<div class="sc-offer-icons"><div class="promo-offer-icon lb-promo-offer-hightlight" style="margin-left: 4px;"></div> <div class="promo-offer-icon"></div></div></div>
369</a><a href="https://funpay.com/lots/offer?id=58821247" class="tc-item" data-online="1" data-f-quantity="13 звёзд" data-f-method="подарком">
370<div class="tc-desc">
371<div class="tc-desc-text">13 звёзд, Подарком</div>
372</div>
373<div class="tc-user">
374<div class="media media-user online style-circle">
375<div class="media-left">
376<div class="avatar-photo pseudo-a" tabindex="0" data-href="https://funpay.com/users/17151546/" style="background-image: url(https://sfunpay.com/s/avatar/19/b9/19b9tuf6mqnwt0fn71xj.jpg);"></div>
377</div>
378<div class="media-body">
379<div class="media-user-name">
380<span class="pseudo-a" tabindex="0" data-href="https://funpay.com/users/17151546/">Ksannyaa</span>
381</div>
382<div class="media-user-reviews">35 отзывов</div>
383<div class="media-user-info">на сайте месяц</div>
384</div>
385</div>
386</div><div class="tc-price" data-s="19.038697">
387<div>19.04 <span class="unit">₽</span></div>
388</div>
389</a>
390<a href="https://funpay.com/lots/offer?id=51391953" class="tc-item offer-promo" data-online="1" data-user="16023197" data-f-quantity="13 звёзд" data-f-method="подарком">
391<div class="tc-desc">
392<div class="tc-desc-text">13 звёзд, Подарком</div>
393</div>
394<div class="tc-user">
395<div class="media media-user online style-circle">
396<div class="media-left">
397<div class="avatar-photo pseudo-a" tabindex="0" data-href="https://funpay.com/users/16023197/" style="background-image: url(https://sfunpay.com/s/avatar/7y/we/7ywe4fo04xk4vx9wo89v.jpg);"></div>
398</div>
399<div class="media-body">
400<div class="media-user-name">
401<span class="pseudo-a" tabindex="0" data-href="https://funpay.com/users/16023197/">starsTGgreat</span>
402</div>
403<div class="media-user-reviews">
404<div class="rating-stars rating-5"><i class="fas"></i><i class="fas"></i><i class="fas"></i><i class="fas"></i><i class="fas"></i></div><span class="rating-mini-count">5876</span>
405</div>
406<div class="media-user-info">на сайте 4 месяца</div>
407</div>
408</div>
409</div><div class="tc-price" data-s="19.154786">
410<div>19.15 <span class="unit">₽</span></div>
411<div class="sc-offer-icons"><div class="promo-offer-icon lb-promo-offer-hightlight" style="margin-left: 4px;"></div></div></div>
412</a>
413"#;
414
415    #[test]
416    fn test_parse_market_offers() {
417        let offers = parse_market_offers(TEST_HTML, 2418);
418
419        assert_eq!(offers.len(), 3);
420
421        let first = &offers[0];
422        assert_eq!(first.id, 58789647);
423        assert_eq!(first.node_id, 2418);
424        assert_eq!(first.description, "13 звёзд, Подарком");
425        assert!((first.price - 20.89613).abs() < 0.0001);
426        assert_eq!(first.currency, "₽");
427        assert_eq!(first.seller_id, 4029757);
428        assert_eq!(first.seller_name, "Ded777Veka");
429        assert!(first.seller_online);
430        assert_eq!(first.seller_reviews, 220);
431        assert!(first.is_promo);
432
433        let second = &offers[1];
434        assert_eq!(second.id, 58821247);
435        assert_eq!(second.description, "13 звёзд, Подарком");
436        assert!((second.price - 19.038697).abs() < 0.0001);
437        assert_eq!(second.currency, "₽");
438        assert_eq!(second.seller_id, 17151546);
439        assert_eq!(second.seller_name, "Ksannyaa");
440        assert!(second.seller_online);
441        assert_eq!(second.seller_reviews, 35);
442        assert!(!second.is_promo);
443
444        let third = &offers[2];
445        assert_eq!(third.id, 51391953);
446        assert_eq!(third.description, "13 звёзд, Подарком");
447        assert!((third.price - 19.154786).abs() < 0.0001);
448        assert_eq!(third.currency, "₽");
449        assert_eq!(third.seller_id, 16023197);
450        assert_eq!(third.seller_name, "starsTGgreat");
451        assert!(third.seller_online);
452        assert_eq!(third.seller_reviews, 5876);
453        assert!(third.is_promo);
454    }
455
456    #[test]
457    fn test_parse_market_offers_empty_page() {
458        let html = r#"<div class="tc"></div>"#;
459        let offers = parse_market_offers(html, 2418);
460        assert!(offers.is_empty());
461    }
462
463    #[test]
464    fn test_parse_market_offers_skips_invalid() {
465        let html = r#"
466            <a href="https://funpay.com/lots/offer" class="tc-item">
467                <div class="tc-desc-text">No ID offer</div>
468            </a>
469            <a href="https://funpay.com/lots/offer?id=123" class="tc-item" data-online="1">
470                <div class="tc-desc-text">Valid offer</div>
471                <div class="tc-price" data-s="100.0">
472                    <span class="unit">₽</span>
473                </div>
474                <span class="pseudo-a" data-href="https://funpay.com/users/999/">Seller</span>
475            </a>
476        "#;
477
478        let offers = parse_market_offers(html, 1234);
479
480        assert_eq!(offers.len(), 1);
481        assert_eq!(offers[0].id, 123);
482        assert_eq!(offers[0].node_id, 1234);
483    }
484}