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}