1pub mod site;
2pub mod types;
3
4pub use site::FiveNineOneSite;
5pub use types::{
6 AreaRange, AreaValue, Community, CommunityDetail, CommunityRankItem, CommunityRanks,
7 ContentUnit, CoordArea, CrawlOptions, DealTime, HighValueListing, HighValueParams,
8 LabelledValue, MapCoord, NearbyCommunity, NewhouseDetail, NewhouseHousing, NewhouseLayoutBlock,
9 NewhouseMarket, NewhouseMarketItem, NewhouseMarketRoom, NewhouseModules,
10 NewhouseNearbyBusiness, NewhouseNearbyComm, NewhouseNearbyMarket, NewhousePage, NewhousePhoto,
11 NewhousePhotoCategory, NewhousePoi, NewhousePriceList, NewhouseProject, NewhouseSaleCtrlInfo,
12 NewhouseSaleCtrlPrice, NewhouseSalesAgent, NewhouseSurrounding, NewhouseSurroundingFacility,
13 NewhouseSurroundingHousing, PendingArea, PendingPrice, PendingRoom, PriceRange, PriceRecord,
14 PriceValue, Region, RentAddress, RentDetail, RentFactTable, RentLinkInfo, RentLinkInfoExt,
15 RentNoticeItem, RentPhoto, RentPhotoGroup, RentPublish, RentRemark, RentServiceItem,
16 RentServiceTable, RentSurround, RentSurroundCategory, RentSurroundPoi, RentTag, SaleDetail,
17 SaleHouseListing, SaleHousePage, SaleListing, SaleSimilarWare, SearchListing, SearchParams,
18};
19
20use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, REFERER};
21use tail_fin_common::TailFinError;
22use types::{DetailResponse, HotResponse};
23
24const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
25const HOT_URL: &str = "https://api.591.com.tw/api/community/rentHot";
26const DETAIL_URL: &str = "https://api.591.com.tw/api/community/detail";
27const NEARBY_URL: &str = "https://api.591.com.tw/api/community/nearby";
28const SALE_LIST_URL: &str = "https://bff-house.591.com.tw/v1/web/sale/list";
29const NEWHOUSE_LIST_URL: &str = "https://bff-newhouse.591.com.tw/v1/list-search";
30const COMMUNITY_RANK_URL: &str = "https://bff.591.com.tw/v1/community/community-rank";
31const RENT_LIST_URL: &str = "https://bff-house.591.com.tw/v3/web/rent/list";
32const NEWHOUSE_BASE_INFO_URL: &str = "https://bff-newhouse.591.com.tw/v1/detail/base-info";
33const NEWHOUSE_MODULE_INFO_URL: &str = "https://bff-newhouse.591.com.tw/v1/detail/module-info";
34const NEWHOUSE_PHOTOS_URL: &str = "https://bff-newhouse.591.com.tw/v1/detail/photos";
35const NEWHOUSE_SURROUNDING_URL: &str = "https://bff-newhouse.591.com.tw/v1/detail/surrounding";
36const NEWHOUSE_NEARBY_MARKET_URL: &str = "https://bff-newhouse.591.com.tw/v1/detail/nearby-market";
37const NEWHOUSE_PRICE_LIST_URL: &str = "https://bff-newhouse.591.com.tw/v1/price/list";
38const HIGH_VALUE_SEARCH_URL: &str = "https://bff-house.591.com.tw/v1/high-value/search";
39const COORDINATE_AREA_URL: &str = "https://bff.591.com.tw/v1/coordinate/area";
40const RENT_DETAIL_URL: &str = "https://bff-house.591.com.tw/v2/web/rent/detail";
41const RENT_PHOTOS_URL: &str = "https://bff-house.591.com.tw/v1/ware/photos";
42const SALE_DETAIL_URL: &str = "https://bff-house.591.com.tw/v1/touch/sale/detail";
43const SALE_SIMILAR_URL: &str = "https://bff-house.591.com.tw/v2/web/sale/similar-wares";
44
45const BFF_DEVICE_HEADER_VALUE: &str = "touch";
52const BFF_DEVICEID_VALUE: &str = "tail-fin-rust-client";
53
54pub const RENT_PAGE_SIZE: usize = 30;
59
60const HIGH_DROP_RATIO: f64 = 0.5;
64
65fn warn_if_high_drop(endpoint: &str, raw_count: usize, kept: usize) {
69 if raw_count == 0 || kept >= raw_count {
70 return;
71 }
72 let dropped = raw_count - kept;
73 if (dropped as f64) / (raw_count as f64) > HIGH_DROP_RATIO {
74 eprintln!(
75 "[tail-fin-591] {endpoint}: filter dropped {dropped}/{raw_count} items \
76 (>{:.0}% — possible 591-side schema change)",
77 HIGH_DROP_RATIO * 100.0
78 );
79 }
80}
81
82const SALE_REFERER: &str = "https://sale.591.com.tw/";
84const NEWHOUSE_REFERER: &str = "https://newhouse.591.com.tw/";
86const WWW_REFERER: &str = "https://www.591.com.tw/";
88
89pub const REGIONS: &[Region] = &[
91 Region {
92 id: 1,
93 name: "台北市",
94 },
95 Region {
96 id: 2,
97 name: "新北市",
98 },
99 Region {
100 id: 3,
101 name: "桃園市",
102 },
103 Region {
104 id: 4,
105 name: "台中市",
106 },
107 Region {
108 id: 5,
109 name: "台南市",
110 },
111 Region {
112 id: 6,
113 name: "高雄市",
114 },
115 Region {
116 id: 7,
117 name: "基隆市",
118 },
119 Region {
120 id: 8,
121 name: "新竹市",
122 },
123 Region {
124 id: 9,
125 name: "嘉義市",
126 },
127 Region {
128 id: 10,
129 name: "新竹縣",
130 },
131 Region {
132 id: 11,
133 name: "苗栗縣",
134 },
135 Region {
136 id: 12,
137 name: "彰化縣",
138 },
139 Region {
140 id: 13,
141 name: "南投縣",
142 },
143 Region {
144 id: 14,
145 name: "雲林縣",
146 },
147 Region {
148 id: 15,
149 name: "嘉義縣",
150 },
151 Region {
152 id: 16,
153 name: "屏東縣",
154 },
155 Region {
156 id: 17,
157 name: "宜蘭縣",
158 },
159 Region {
160 id: 18,
161 name: "花蓮縣",
162 },
163 Region {
164 id: 19,
165 name: "台東縣",
166 },
167 Region {
168 id: 20,
169 name: "澎湖縣",
170 },
171 Region {
172 id: 21,
173 name: "金門縣",
174 },
175 Region {
176 id: 22,
177 name: "連江縣",
178 },
179];
180
181pub struct Client591 {
185 client: reqwest::Client,
186}
187
188impl Client591 {
189 pub fn new() -> Result<Self, TailFinError> {
191 let mut headers = HeaderMap::new();
192 headers.insert(
193 ACCEPT,
194 HeaderValue::from_static("application/json, text/plain, */*"),
195 );
196 headers.insert(
197 REFERER,
198 HeaderValue::from_static("https://rent.591.com.tw/"),
199 );
200
201 let client = reqwest::Client::builder()
202 .user_agent(USER_AGENT)
203 .default_headers(headers)
204 .build()
205 .map_err(|e| TailFinError::Api(e.to_string()))?;
206
207 Ok(Self { client })
208 }
209
210 pub async fn hot(&self, region_id: u32, limit: usize) -> Result<Vec<Community>, TailFinError> {
216 let resp: HotResponse = self
217 .client
218 .get(HOT_URL)
219 .query(&[("region_id", region_id.to_string())])
220 .send()
221 .await
222 .map_err(|e| TailFinError::Api(e.to_string()))?
223 .json()
224 .await
225 .map_err(|e| TailFinError::Parse(e.to_string()))?;
226
227 if resp.status != 1 {
228 return Err(TailFinError::Api(format!(
229 "591 API returned status {}",
230 resp.status
231 )));
232 }
233
234 let mut items = resp.data.unwrap_or_default();
235 items.truncate(limit);
236 Ok(items)
237 }
238
239 pub async fn community(&self, id: u64) -> Result<Option<CommunityDetail>, TailFinError> {
241 let resp = self.fetch_detail(id).await?;
242 Ok(resp.and_then(|d| d.community))
243 }
244
245 pub async fn price_history(
249 &self,
250 id: u64,
251 limit: usize,
252 ) -> Result<Vec<PriceRecord>, TailFinError> {
253 let data = self.fetch_detail(id).await?;
254 let mut records = data
255 .and_then(|d| d.price)
256 .map(|p| p.items)
257 .unwrap_or_default();
258 records.truncate(limit);
259 Ok(records)
260 }
261
262 pub async fn nearby(
269 &self,
270 id: u64,
271 limit: usize,
272 ) -> Result<Vec<NearbyCommunity>, TailFinError> {
273 let resp: types::NearbyResponse = self
274 .client
275 .get(NEARBY_URL)
276 .query(&[("id", id.to_string())])
277 .send()
278 .await
279 .map_err(|e| TailFinError::Api(e.to_string()))?
280 .json()
281 .await
282 .map_err(|e| TailFinError::Parse(e.to_string()))?;
283
284 if resp.status != 1 {
285 return Err(TailFinError::Api(format!(
286 "591 nearby returned status {}",
287 resp.status
288 )));
289 }
290
291 let mut items: Vec<NearbyCommunity> = resp
292 .data
293 .unwrap_or_default()
294 .into_iter()
295 .map(NearbyCommunity::from)
296 .collect();
297 items.truncate(limit);
298 Ok(items)
299 }
300
301 pub async fn sale_list(
310 &self,
311 region_id: u32,
312 first_row: usize,
313 limit: usize,
314 ) -> Result<SaleHousePage, TailFinError> {
315 let resp: types::SaleListResponse = self
316 .client
317 .get(SALE_LIST_URL)
318 .header(REFERER, SALE_REFERER)
319 .query(&[
320 ("type", "2"),
321 ("category", "1"),
322 ("regionid", ®ion_id.to_string()),
323 ("firstRow", &first_row.to_string()),
324 ("shType", "list"),
325 ])
326 .send()
327 .await
328 .map_err(|e| TailFinError::Api(e.to_string()))?
329 .json()
330 .await
331 .map_err(|e| TailFinError::Parse(e.to_string()))?;
332
333 if resp.status != 1 {
334 return Err(TailFinError::Api(format!(
335 "591 sale_list returned status {}",
336 resp.status
337 )));
338 }
339
340 let data = resp.data.unwrap_or_default();
341 let raw_count = data.house_list.len();
348 let houses: Vec<SaleHouseListing> = data
349 .house_list
350 .into_iter()
351 .filter_map(|v| serde_json::from_value(v).ok())
352 .take(limit)
353 .collect();
354 warn_if_high_drop("sale_list", raw_count, houses.len());
355 Ok(SaleHousePage {
356 total: data.total,
357 first_row,
358 houses,
359 })
360 }
361
362 pub async fn newhouse_list(
367 &self,
368 region_id: u32,
369 page: u32,
370 ) -> Result<NewhousePage, TailFinError> {
371 let resp: types::NewhouseListResponse = self
372 .client
373 .get(NEWHOUSE_LIST_URL)
374 .header(REFERER, NEWHOUSE_REFERER)
375 .query(&[
376 ("page", &page.to_string()),
377 ("device", &"pc".to_string()),
378 ("regionid", ®ion_id.to_string()),
379 ])
380 .send()
381 .await
382 .map_err(|e| TailFinError::Api(e.to_string()))?
383 .json()
384 .await
385 .map_err(|e| TailFinError::Parse(e.to_string()))?;
386
387 if resp.status != 1 {
388 return Err(TailFinError::Api(format!(
389 "591 newhouse_list returned status {}",
390 resp.status
391 )));
392 }
393 let data = resp
394 .data
395 .ok_or_else(|| TailFinError::Api("591 newhouse_list returned no data".into()))?;
396 let raw_count = data.items.len();
400 let items: Vec<NewhouseProject> = data
401 .items
402 .into_iter()
403 .filter_map(|v| serde_json::from_value(v).ok())
404 .collect();
405 warn_if_high_drop("newhouse_list", raw_count, items.len());
406 Ok(NewhousePage {
407 total: data.total,
408 online_total: data.online_total,
409 page: data.page,
410 per_page: data.per_page,
411 total_page: data.total_page,
412 items,
413 })
414 }
415
416 fn newhouse_get(&self, url: &str) -> reqwest::RequestBuilder {
421 self.client
422 .get(url)
423 .header(REFERER, NEWHOUSE_REFERER)
424 .header("Origin", "https://newhouse.591.com.tw")
425 .header("device", BFF_DEVICE_HEADER_VALUE)
426 .header("deviceid", BFF_DEVICEID_VALUE)
427 }
428
429 pub async fn newhouse_base_info(&self, hid: u64) -> Result<NewhouseHousing, TailFinError> {
438 let resp: types::NewhouseBaseInfoResponse = self
439 .newhouse_get(NEWHOUSE_BASE_INFO_URL)
440 .query(&[
441 ("id", hid.to_string()),
442 ("region_id", "1".to_string()),
443 ("is_auth", "0".to_string()),
444 ])
445 .send()
446 .await
447 .map_err(|e| TailFinError::Api(e.to_string()))?
448 .json()
449 .await
450 .map_err(|e| TailFinError::Parse(e.to_string()))?;
451
452 if resp.status != 1 {
453 return Err(TailFinError::Api(format!(
454 "591 newhouse_base_info returned status {} ({})",
455 resp.status,
456 resp.msg.as_deref().unwrap_or("")
457 )));
458 }
459 resp.data
460 .and_then(|d| d.housing)
461 .ok_or_else(|| TailFinError::Api(format!("591 newhouse {hid} not found")))
462 }
463
464 pub async fn newhouse_module_info(&self, hid: u64) -> Result<NewhouseModules, TailFinError> {
472 let resp: types::NewhouseModuleInfoResponse = self
473 .newhouse_get(NEWHOUSE_MODULE_INFO_URL)
474 .query(&[
475 ("id", hid.to_string()),
476 ("region_id", "1".to_string()),
477 ("is_auth", "0".to_string()),
478 ])
479 .send()
480 .await
481 .map_err(|e| TailFinError::Api(e.to_string()))?
482 .json()
483 .await
484 .map_err(|e| TailFinError::Parse(e.to_string()))?;
485
486 if resp.status != 1 {
487 return Err(TailFinError::Api(format!(
488 "591 newhouse_module_info returned status {} ({})",
489 resp.status,
490 resp.msg.as_deref().unwrap_or("")
491 )));
492 }
493 let data = resp
494 .data
495 .ok_or_else(|| TailFinError::Api(format!("591 newhouse {hid} not found")))?;
496 Ok(NewhouseModules {
497 layout: data.layout,
498 market: data.market,
499 sales_agents: data.sales.data,
500 })
501 }
502
503 pub async fn newhouse_photos(
510 &self,
511 hid: u64,
512 ) -> Result<Vec<NewhousePhotoCategory>, TailFinError> {
513 let resp: types::NewhousePhotosResponse = self
514 .newhouse_get(NEWHOUSE_PHOTOS_URL)
515 .query(&[("id", hid.to_string()), ("is_auth", "0".to_string())])
516 .send()
517 .await
518 .map_err(|e| TailFinError::Api(e.to_string()))?
519 .json()
520 .await
521 .map_err(|e| TailFinError::Parse(e.to_string()))?;
522
523 if resp.status != 1 {
524 return Err(TailFinError::Api(format!(
525 "591 newhouse_photos returned status {} ({})",
526 resp.status,
527 resp.msg.as_deref().unwrap_or("")
528 )));
529 }
530 Ok(resp.data.unwrap_or_default())
531 }
532
533 pub async fn newhouse_surrounding(
538 &self,
539 hid: u64,
540 ) -> Result<NewhouseSurrounding, TailFinError> {
541 let resp: types::NewhouseSurroundingResponse = self
542 .newhouse_get(NEWHOUSE_SURROUNDING_URL)
543 .query(&[("id", hid.to_string()), ("is_auth", "0".to_string())])
544 .send()
545 .await
546 .map_err(|e| TailFinError::Api(e.to_string()))?
547 .json()
548 .await
549 .map_err(|e| TailFinError::Parse(e.to_string()))?;
550
551 if resp.status != 1 {
552 return Err(TailFinError::Api(format!(
553 "591 newhouse_surrounding returned status {} ({})",
554 resp.status,
555 resp.msg.as_deref().unwrap_or("")
556 )));
557 }
558 resp.data
559 .ok_or_else(|| TailFinError::Api(format!("591 newhouse {hid} not found")))
560 }
561
562 pub async fn newhouse_nearby_market(
569 &self,
570 hid: u64,
571 ) -> Result<NewhouseNearbyMarket, TailFinError> {
572 let resp: types::NewhouseNearbyMarketResponse = self
573 .newhouse_get(NEWHOUSE_NEARBY_MARKET_URL)
574 .query(&[("hid", hid.to_string())])
575 .send()
576 .await
577 .map_err(|e| TailFinError::Api(e.to_string()))?
578 .json()
579 .await
580 .map_err(|e| TailFinError::Parse(e.to_string()))?;
581
582 if resp.status != 1 {
583 return Err(TailFinError::Api(format!(
584 "591 newhouse_nearby_market returned status {} ({})",
585 resp.status,
586 resp.msg.as_deref().unwrap_or("")
587 )));
588 }
589 Ok(resp.data.unwrap_or(NewhouseNearbyMarket {
590 community_items: vec![],
591 business_items: vec![],
592 }))
593 }
594
595 pub async fn newhouse_price_list(&self, hid: u64) -> Result<NewhousePriceList, TailFinError> {
605 let resp: types::NewhousePriceListResponse = self
606 .newhouse_get(NEWHOUSE_PRICE_LIST_URL)
607 .query(&[
608 ("id", hid.to_string()),
609 ("region_id", "1".to_string()),
610 ("trans_type", "1".to_string()),
611 ("room", "0".to_string()),
612 ("from", "detail".to_string()),
613 ])
614 .send()
615 .await
616 .map_err(|e| TailFinError::Api(e.to_string()))?
617 .json()
618 .await
619 .map_err(|e| TailFinError::Parse(e.to_string()))?;
620
621 if resp.status != 1 {
622 return Err(TailFinError::Api(format!(
623 "591 newhouse_price_list returned status {} ({})",
624 resp.status,
625 resp.msg.as_deref().unwrap_or("")
626 )));
627 }
628 resp.data
629 .ok_or_else(|| TailFinError::Api(format!("591 newhouse {hid} not found")))
630 }
631
632 pub async fn newhouse_detail(&self, hid: u64) -> NewhouseDetail {
644 let (base, modules, photos, surround, nearby, price) = tokio::join!(
645 self.newhouse_base_info(hid),
646 self.newhouse_module_info(hid),
647 self.newhouse_photos(hid),
648 self.newhouse_surrounding(hid),
649 self.newhouse_nearby_market(hid),
650 self.newhouse_price_list(hid),
651 );
652 let (housing, housing_error) = match base {
653 Ok(v) => (Some(v), None),
654 Err(e) => (None, Some(e.to_string())),
655 };
656 let (modules, modules_error) = match modules {
657 Ok(v) => (Some(v), None),
658 Err(e) => (None, Some(e.to_string())),
659 };
660 let (photos_vec, photos_error) = match photos {
661 Ok(v) => (v, None),
662 Err(e) => (vec![], Some(e.to_string())),
663 };
664 let (surrounding, surrounding_error) = match surround {
665 Ok(v) => (Some(v), None),
666 Err(e) => (None, Some(e.to_string())),
667 };
668 let (nearby_market, nearby_market_error) = match nearby {
669 Ok(v) => (Some(v), None),
670 Err(e) => (None, Some(e.to_string())),
671 };
672 let (price_list, price_list_error) = match price {
673 Ok(v) => (Some(v), None),
674 Err(e) => (None, Some(e.to_string())),
675 };
676 NewhouseDetail {
677 housing,
678 housing_error,
679 modules,
680 modules_error,
681 photos: photos_vec,
682 photos_error,
683 surrounding,
684 surrounding_error,
685 nearby_market,
686 nearby_market_error,
687 price_list,
688 price_list_error,
689 }
690 }
691
692 pub async fn community_rank(&self, region_id: u32) -> Result<CommunityRanks, TailFinError> {
697 let resp: types::CommunityRankResponse = self
698 .client
699 .get(COMMUNITY_RANK_URL)
700 .header(REFERER, WWW_REFERER)
701 .query(&[("regionid", region_id.to_string())])
702 .send()
703 .await
704 .map_err(|e| TailFinError::Api(e.to_string()))?
705 .json()
706 .await
707 .map_err(|e| TailFinError::Parse(e.to_string()))?;
708
709 if resp.status != 1 {
710 return Err(TailFinError::Api(format!(
711 "591 community_rank returned status {}",
712 resp.status
713 )));
714 }
715 let data = resp
716 .data
717 .ok_or_else(|| TailFinError::Api("591 community_rank returned no data".into()))?;
718 Ok(CommunityRanks {
721 price_data: data.price_data.data,
722 sale_data: data.sale_data.data,
723 time: data.price_data.time,
724 })
725 }
726
727 pub async fn rent_search(
740 &self,
741 params: &SearchParams,
742 ) -> Result<(u32, Vec<SearchListing>), TailFinError> {
743 let region = params.region_id.to_string();
744 let first_row = params.first_row.to_string();
745 let mut query: Vec<(&str, String)> = vec![("regionid", region), ("firstRow", first_row)];
746 if let Some(k) = params.kind {
747 query.push(("kind", k.to_string()));
748 }
749 if params.price_min.is_some() || params.price_max.is_some() {
750 let lo = params.price_min.map(|p| p.to_string()).unwrap_or_default();
751 let hi = params.price_max.map(|p| p.to_string()).unwrap_or_default();
752 query.push(("multiPrice", format!("{lo}_{hi}")));
753 }
754 if let Some(o) = ¶ms.order {
755 query.push(("order", o.clone()));
756 }
757
758 let resp: types::RentListResponse = self
759 .client
760 .get(RENT_LIST_URL)
761 .query(&query)
762 .send()
763 .await
764 .map_err(|e| TailFinError::Api(e.to_string()))?
765 .json()
766 .await
767 .map_err(|e| TailFinError::Parse(e.to_string()))?;
768
769 if resp.status != 1 {
770 return Err(TailFinError::Api(format!(
771 "591 rent_search returned status {}",
772 resp.status
773 )));
774 }
775
776 let data = resp
777 .data
778 .ok_or_else(|| TailFinError::Api("591 rent_search returned no data".into()))?;
779
780 let total = data.total;
781 let limit = params.limit.max(1);
782 let listings: Vec<SearchListing> = data
783 .items
784 .into_iter()
785 .take(limit)
786 .map(SearchListing::from)
787 .collect();
788 Ok((total, listings))
789 }
790
791 pub async fn rent_crawl<F>(
800 &self,
801 params: &SearchParams,
802 opts: &CrawlOptions,
803 mut on_page: F,
804 ) -> Result<usize, TailFinError>
805 where
806 F: FnMut(usize, usize, &[SearchListing]),
807 {
808 let mut total_fetched = 0;
809 let mut page = opts.start_page;
810 let mut pages_fetched = 0;
811
812 loop {
813 let first_row = page * RENT_PAGE_SIZE;
814 let page_params = SearchParams {
815 first_row,
816 limit: RENT_PAGE_SIZE,
817 ..params.clone()
818 };
819
820 let listings = {
821 let mut last_err: Option<TailFinError> = None;
822 let mut result: Option<Vec<SearchListing>> = None;
823 for attempt in 0..=opts.retries {
824 match self.rent_search(&page_params).await {
825 Ok((_total, items)) => {
826 result = Some(items);
827 break;
828 }
829 Err(e) => {
830 if attempt < opts.retries {
831 eprintln!(
832 "[crawl] page {} attempt {}/{} failed: {}; retrying in 2s",
833 page + 1,
834 attempt + 1,
835 opts.retries,
836 e
837 );
838 tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
839 }
840 last_err = Some(e);
841 }
842 }
843 }
844 match result {
845 Some(items) => items,
846 None => return Err(last_err.unwrap()),
847 }
848 };
849
850 let n = listings.len();
851 if n > 0 {
852 on_page(page, first_row, &listings);
853 total_fetched += n;
854 }
855 pages_fetched += 1;
856
857 let max_reached = opts.max_pages > 0 && pages_fetched >= opts.max_pages;
858 let last_page = n < RENT_PAGE_SIZE;
859 if last_page || max_reached {
860 break;
861 }
862 if opts.delay_ms > 0 {
863 tokio::time::sleep(tokio::time::Duration::from_millis(opts.delay_ms)).await;
864 }
865 page += 1;
866 }
867
868 Ok(total_fetched)
869 }
870
871 pub async fn high_value_search(
886 &self,
887 params: &HighValueParams,
888 ) -> Result<Vec<HighValueListing>, TailFinError> {
889 let resp: types::HighValueSearchResponse = self
890 .client
891 .post(HIGH_VALUE_SEARCH_URL)
892 .header(REFERER, SALE_REFERER)
893 .header("Origin", "https://sale.591.com.tw")
894 .json(params)
895 .send()
896 .await
897 .map_err(|e| TailFinError::Api(e.to_string()))?
898 .json()
899 .await
900 .map_err(|e| TailFinError::Parse(e.to_string()))?;
901
902 if resp.status != 1 {
903 return Err(TailFinError::Api(format!(
904 "591 high_value_search returned status {} ({})",
905 resp.status, resp.msg
906 )));
907 }
908 Ok(resp.data)
909 }
910
911 pub async fn coordinate_area(
930 &self,
931 latitude: f64,
932 longitude: f64,
933 region_id: u32,
934 ) -> Result<Option<CoordArea>, TailFinError> {
935 let resp: types::CoordResponse = self
936 .client
937 .get(COORDINATE_AREA_URL)
938 .header(REFERER, NEWHOUSE_REFERER)
939 .query(&[
940 ("latitude", latitude.to_string()),
941 ("longitude", longitude.to_string()),
942 ("region_id", region_id.to_string()),
943 ("device", "touch".to_string()),
944 ])
945 .send()
946 .await
947 .map_err(|e| TailFinError::Api(e.to_string()))?
948 .json()
949 .await
950 .map_err(|e| TailFinError::Parse(e.to_string()))?;
951
952 match resp.status {
957 1 => {}
958 0 => return Ok(None),
959 other => {
960 return Err(TailFinError::Api(format!(
961 "591 coordinate_area returned status {other} ({})",
962 resp.msg
963 )));
964 }
965 }
966 let area_value = resp.data.get("area").cloned().ok_or_else(|| {
974 TailFinError::Parse(format!(
975 "unexpected data shape on hit (expected object with 'area' key, got {})",
976 if resp.data.is_array() {
977 "array"
978 } else if resp.data.is_object() {
979 "object without 'area' key"
980 } else {
981 "non-object"
982 }
983 ))
984 })?;
985 let area: CoordArea =
986 serde_json::from_value(area_value).map_err(|e| TailFinError::Parse(e.to_string()))?;
987 Ok(Some(area))
988 }
989
990 pub async fn rent_detail(&self, post_id: u64) -> Result<RentDetail, TailFinError> {
1001 let resp: types::RentDetailResponse = self
1002 .client
1003 .get(RENT_DETAIL_URL)
1004 .query(&[("id", post_id.to_string())])
1005 .send()
1006 .await
1007 .map_err(|e| TailFinError::Api(e.to_string()))?
1008 .json()
1009 .await
1010 .map_err(|e| TailFinError::Parse(e.to_string()))?;
1011
1012 if resp.status != 1 {
1013 return Err(TailFinError::Api(format!(
1014 "591 rent_detail returned status {} ({})",
1015 resp.status, resp.msg
1016 )));
1017 }
1018 resp.data
1019 .ok_or_else(|| TailFinError::Api(format!("591 rent listing {post_id} not found")))
1020 }
1021
1022 pub async fn rent_photos(&self, post_id: u64) -> Result<Vec<RentPhotoGroup>, TailFinError> {
1030 let resp: types::RentPhotosResponse = self
1031 .client
1032 .get(RENT_PHOTOS_URL)
1033 .query(&[("id", post_id.to_string()), ("type", "1".to_string())])
1034 .send()
1035 .await
1036 .map_err(|e| TailFinError::Api(e.to_string()))?
1037 .json()
1038 .await
1039 .map_err(|e| TailFinError::Parse(e.to_string()))?;
1040
1041 if resp.status != 1 {
1042 return Err(TailFinError::Api(format!(
1043 "591 rent_photos returned status {} ({})",
1044 resp.status, resp.msg
1045 )));
1046 }
1047 Ok(resp.data.map(|d| d.list).unwrap_or_default())
1048 }
1049
1050 pub async fn sale_detail(&self, post_id: u64) -> Result<SaleDetail, TailFinError> {
1064 let id = format!("S{post_id}");
1065 let resp: types::SaleDetailResponse = self
1066 .client
1067 .get(SALE_DETAIL_URL)
1068 .header(REFERER, SALE_REFERER)
1069 .header("Origin", "https://sale.591.com.tw")
1070 .query(&[
1071 ("id", id.as_str()),
1072 ("is_business", "0"),
1073 ("device_id", BFF_DEVICEID_VALUE),
1074 ("__v__", "1"),
1075 ("region_id", "1"),
1076 ("device", "touch"),
1077 ])
1078 .send()
1079 .await
1080 .map_err(|e| TailFinError::Api(e.to_string()))?
1081 .json()
1082 .await
1083 .map_err(|e| TailFinError::Parse(e.to_string()))?;
1084
1085 if resp.status != 1 {
1086 return Err(TailFinError::Api(format!(
1087 "591 sale_detail returned status {} ({})",
1088 resp.status,
1089 resp.msg.as_deref().unwrap_or("")
1090 )));
1091 }
1092 resp.data
1093 .ok_or_else(|| TailFinError::Api(format!("591 sale listing {post_id} not found")))
1094 }
1095
1096 pub async fn sale_similar_wares(
1103 &self,
1104 post_id: u64,
1105 ) -> Result<Vec<SaleSimilarWare>, TailFinError> {
1106 let resp: types::SaleSimilarWaresResponse = self
1107 .client
1108 .get(SALE_SIMILAR_URL)
1109 .header(REFERER, SALE_REFERER)
1110 .header("Origin", "https://sale.591.com.tw")
1111 .query(&[
1112 ("id", post_id.to_string()),
1113 ("region_id", "1".to_string()),
1114 ("device", "touch".to_string()),
1115 ])
1116 .send()
1117 .await
1118 .map_err(|e| TailFinError::Api(e.to_string()))?
1119 .json()
1120 .await
1121 .map_err(|e| TailFinError::Parse(e.to_string()))?;
1122
1123 if resp.status != 1 {
1124 return Err(TailFinError::Api(format!(
1125 "591 sale_similar_wares returned status {} ({})",
1126 resp.status,
1127 resp.msg.as_deref().unwrap_or("")
1128 )));
1129 }
1130 Ok(resp.data)
1131 }
1132
1133 pub async fn sales(
1137 &self,
1138 id: u64,
1139 limit: usize,
1140 ) -> Result<(u32, Vec<SaleListing>), TailFinError> {
1141 let data = self.fetch_detail(id).await?;
1142 let sale = data.and_then(|d| d.sale);
1143 let total = sale.as_ref().map(|s| s.total).unwrap_or(0);
1144 let mut listings: Vec<SaleListing> = sale
1145 .map(|s| s.rooms.into_iter().flat_map(|r| r.items).collect())
1146 .unwrap_or_default();
1147 listings.truncate(limit);
1148 Ok((total, listings))
1149 }
1150
1151 async fn fetch_detail(&self, id: u64) -> Result<Option<types::DetailData>, TailFinError> {
1153 let resp: DetailResponse = self
1154 .client
1155 .get(DETAIL_URL)
1156 .query(&[("id", id.to_string())])
1157 .send()
1158 .await
1159 .map_err(|e| TailFinError::Api(e.to_string()))?
1160 .json()
1161 .await
1162 .map_err(|e| TailFinError::Parse(e.to_string()))?;
1163
1164 if resp.status != 1 {
1165 return Ok(None);
1166 }
1167
1168 Ok(resp.data)
1169 }
1170}
1171
1172#[cfg(test)]
1177mod tests {
1178 use super::*;
1179 use crate::types::{DetailResponse, HotResponse};
1180
1181 #[test]
1182 fn test_client_new() {
1183 let client = Client591::new();
1184 assert!(client.is_ok());
1185 }
1186
1187 #[test]
1188 fn test_rent_list_response_deserialize() {
1189 use crate::types::RentListResponse;
1190 let json = r#"{
1191 "status": 1,
1192 "data": {
1193 "total": "3728",
1194 "firstRow": 0,
1195 "items": [{
1196 "id": 21121520,
1197 "type": 1,
1198 "kind": 2,
1199 "kind_name": "獨立套房",
1200 "title": "中山套房",
1201 "price": "17,500",
1202 "price_unit": "元/月",
1203 "address": "中山區-中山北路一段105巷",
1204 "area_name": "9坪",
1205 "layoutStr": "",
1206 "floor_name": "4F/6F",
1207 "photoList": ["https://img2.591.com.tw/a.jpg"],
1208 "tags": ["近捷運"],
1209 "refresh_time": "7小時內更新",
1210 "regionid": 1,
1211 "sectionid": 3
1212 }]
1213 }
1214 }"#;
1215 let resp: RentListResponse = serde_json::from_str(json).unwrap();
1216 assert_eq!(resp.status, 1);
1217 let data = resp.data.unwrap();
1218 assert_eq!(data.total, 3728);
1219 assert_eq!(data.items.len(), 1);
1220 assert_eq!(data.items[0].id, 21121520);
1221 assert_eq!(data.items[0].title, "中山套房");
1222 }
1223
1224 #[test]
1225 fn test_rent_list_item_into_search_listing_full() {
1226 use crate::types::RentListItem;
1227 let item = RentListItem {
1228 id: 21121520,
1229 title: "中山套房".into(),
1230 price: Some("17,500".into()),
1231 price_unit: Some("元/月".into()),
1232 address: Some("中山區-中山北路".into()),
1233 area_name: Some("9坪".into()),
1234 kind_name: Some("獨立套房".into()),
1235 layout_str: Some("2房1廳".into()),
1236 floor_name: Some("4F/6F".into()),
1237 photo_list: Some(vec!["https://img2.591.com.tw/a.jpg".into()]),
1238 tags: Some(vec!["近捷運".into()]),
1239 refresh_time: Some("7小時內更新".into()),
1240 };
1241 let listing: SearchListing = item.into();
1242 assert_eq!(listing.post_id, 21121520);
1243 assert_eq!(listing.title, "中山套房");
1244 assert_eq!(listing.price.as_deref(), Some("17,500"));
1245 assert_eq!(listing.area.as_deref(), Some("9坪"));
1246 assert_eq!(listing.room.as_deref(), Some("2房1廳"));
1247 assert_eq!(listing.floor.as_deref(), Some("4F/6F"));
1248 assert_eq!(listing.tags.as_ref().map(|v| v.len()), Some(1));
1249 assert_eq!(listing.post_time.as_deref(), Some("7小時內更新"));
1250 }
1251
1252 #[test]
1253 fn test_rent_list_item_into_search_listing_empty_collections_become_none() {
1254 use crate::types::RentListItem;
1255 let item = RentListItem {
1256 id: 1,
1257 title: "x".into(),
1258 price: None,
1259 price_unit: None,
1260 address: None,
1261 area_name: None,
1262 kind_name: None,
1263 layout_str: Some(String::new()),
1264 floor_name: None,
1265 photo_list: Some(vec![]),
1266 tags: Some(vec![]),
1267 refresh_time: None,
1268 };
1269 let listing: SearchListing = item.into();
1270 assert!(listing.room.is_none(), "empty layoutStr should become None");
1271 assert!(listing.photo_list.is_none(), "empty Vec should become None");
1272 assert!(listing.tags.is_none(), "empty Vec should become None");
1273 }
1274
1275 #[test]
1276 fn test_rent_list_response_total_accepts_int() {
1277 use crate::types::RentListResponse;
1282 let json = r#"{"status":1,"data":{"total":3728,"firstRow":0,"items":[]}}"#;
1283 let resp: RentListResponse = serde_json::from_str(json).unwrap();
1284 assert_eq!(resp.data.unwrap().total, 3728);
1285 }
1286
1287 #[test]
1288 fn test_rent_list_response_total_garbage_string_fails_loudly() {
1289 use crate::types::RentListResponse;
1294 let json = r#"{"status":1,"data":{"total":"abc","firstRow":0,"items":[]}}"#;
1295 let result: Result<RentListResponse, _> = serde_json::from_str(json);
1296 assert!(result.is_err(), "garbage total should fail to deserialize");
1297 }
1298
1299 #[test]
1300 fn test_rent_list_response_error_status() {
1301 use crate::types::RentListResponse;
1302 let json = r#"{"status":0,"msg":"參數錯誤"}"#;
1303 let resp: RentListResponse = serde_json::from_str(json).unwrap();
1304 assert_eq!(resp.status, 0);
1305 assert!(resp.data.is_none());
1306 }
1307
1308 #[test]
1309 fn test_hot_response_deserialize() {
1310 let json = r#"{"status":1,"msg":"請求成功","data":[{"id":"123","name":"Test"},{"id":"456","name":"Other"}]}"#;
1311 let resp: HotResponse = serde_json::from_str(json).unwrap();
1312 assert_eq!(resp.status, 1);
1313 let data = resp.data.unwrap();
1314 assert_eq!(data.len(), 2);
1315 assert_eq!(data[0].id, "123");
1316 assert_eq!(data[0].name, "Test");
1317 }
1318
1319 #[test]
1320 fn test_hot_response_empty_data() {
1321 let json = r#"{"status":1,"msg":"請求成功","data":[]}"#;
1322 let resp: HotResponse = serde_json::from_str(json).unwrap();
1323 assert_eq!(resp.data.unwrap().len(), 0);
1324 }
1325
1326 #[test]
1327 fn test_hot_response_error_status() {
1328 let json = r#"{"status":0,"msg":"error"}"#;
1329 let resp: HotResponse = serde_json::from_str(json).unwrap();
1330 assert_eq!(resp.status, 0);
1331 assert!(resp.data.is_none());
1332 }
1333
1334 #[test]
1335 fn test_detail_response_deserialize() {
1336 let json = r#"{
1337 "status": 1,
1338 "data": {
1339 "community": {
1340 "id": 7329,
1341 "name": "台北晶麒",
1342 "region": "台北市",
1343 "section": "萬華區",
1344 "address": "台北市萬華區康定路103號",
1345 "age": "10年",
1346 "floor": "26層",
1347 "house_holds": "687戶",
1348 "lat": "25.0387262",
1349 "lng": "121.5013407",
1350 "build_purpose": "住宅",
1351 "base_area": "1124.00",
1352 "const_company": "興富發建設股份有限公司",
1353 "search_count": "248,275"
1354 },
1355 "price": { "items": [] },
1356 "sale": { "search_type": 1, "total": 0, "rooms": [] }
1357 }
1358 }"#;
1359 let resp: DetailResponse = serde_json::from_str(json).unwrap();
1360 assert_eq!(resp.status, 1);
1361 let data = resp.data.unwrap();
1362 let community = data.community.unwrap();
1363 assert_eq!(community.id, 7329);
1364 assert_eq!(community.name, "台北晶麒");
1365 assert_eq!(community.region.as_deref(), Some("台北市"));
1366 }
1367
1368 #[test]
1369 fn test_price_record_deserialize() {
1370 let json = r#"{
1371 "id": 7238140,
1372 "date": "115-01-20",
1373 "address": "康定路103號 | 18樓之16",
1374 "layout": "1房1廳",
1375 "build_area": "20.00坪",
1376 "total_price": "1,490萬元",
1377 "unit_price": { "price": "74.5", "unit": "萬/坪" },
1378 "shift_floor": "18樓",
1379 "total_floor": "26樓",
1380 "build_purpose_str": "住宅"
1381 }"#;
1382 let record: PriceRecord = serde_json::from_str(json).unwrap();
1383 assert_eq!(record.id, 7238140);
1384 assert_eq!(record.date, "115-01-20");
1385 assert_eq!(record.unit_price.price, "74.5");
1386 }
1387
1388 #[test]
1389 fn test_sale_listing_deserialize() {
1390 let json = r#"{
1391 "houseid": 20003249,
1392 "title": "台北晶麒景觀精緻宅",
1393 "price_v": { "price": "1,925", "unit": "萬" },
1394 "price_unit": "95.0萬/坪",
1395 "room": "1房1廳",
1396 "address": "萬華區-台北晶麒",
1397 "area_v": { "area": "27.83", "unit": "坪" },
1398 "floor": "19樓",
1399 "floor_en": "19F/26F",
1400 "photo_src": "https://img1.591.com.tw/test.jpg",
1401 "label": ["含車位", "有陽台"]
1402 }"#;
1403 let listing: SaleListing = serde_json::from_str(json).unwrap();
1404 assert_eq!(listing.houseid, 20003249);
1405 assert_eq!(listing.price_v.price, "1,925");
1406 assert_eq!(listing.label.len(), 2);
1407 }
1408
1409 #[test]
1410 fn test_detail_response_not_found() {
1411 let json = r#"{"status":0,"msg":"[id]參數錯誤"}"#;
1412 let resp: DetailResponse = serde_json::from_str(json).unwrap();
1413 assert_eq!(resp.status, 0);
1414 assert!(resp.data.is_none());
1415 }
1416
1417 #[test]
1418 fn test_community_serialize_skips_none() {
1419 let detail = CommunityDetail {
1420 id: 1,
1421 name: "Test".to_string(),
1422 region: Some("台北市".to_string()),
1423 section: None,
1424 address: None,
1425 age: None,
1426 floor: None,
1427 house_holds: None,
1428 lat: None,
1429 lng: None,
1430 build_purpose: None,
1431 base_area: None,
1432 const_company: None,
1433 search_count: None,
1434 };
1435 let json = serde_json::to_string(&detail).unwrap();
1436 assert!(json.contains("\"region\""));
1437 assert!(!json.contains("\"section\""));
1438 }
1439
1440 #[test]
1441 fn test_newhouse_base_info_deserialize() {
1442 use crate::types::{NewhouseBaseInfoResponse, NewhouseHousing};
1443 let json = r#"{
1444 "status": 1,
1445 "msg": "成功",
1446 "data": {
1447 "housing": {
1448 "hid": 138145,
1449 "build_name": "春風大院",
1450 "address": "台北市中山區",
1451 "region": "台北市",
1452 "regionid": 1,
1453 "section": "中山區",
1454 "sectionid": 3,
1455 "community_id": 5958967,
1456 "build_type_name": "預售屋",
1457 "purpose_name": "住宅大樓",
1458 "price": {"pending": 1, "price": "待定", "unit": ""},
1459 "area": {"pending": 0, "area": "16~59", "area_min": "16.00", "unit": "坪"},
1460 "layout": {"pending": 0, "layout": "2/3/4", "unit": "房"},
1461 "households": "1幢,1棟,118戶住家",
1462 "manage_cost": {"pending": 0, "price": "150", "unit": "元/坪/月"},
1463 "cover": "https://img.591.com.tw/x.jpg",
1464 "shop_name": "南京復興生活圈",
1465 "tag": ["近捷運", "低公設", "景觀宅"],
1466 "open_sell_time": 202510,
1467 "deal_time": {"type": "finished", "date": "2030年下半年", "deal": 0},
1468 "build_company": "待定",
1469 "sell_company": "巨將創見廣告",
1470 "structural_engine": "SRC",
1471 "floor": "地上18層",
1472 "decorate": "毛胚屋",
1473 "park_ratio": "1:1.03",
1474 "license": "114建字第0132號",
1475 "use_license": "暫無",
1476 "browsenum": 655773,
1477 "fav_num": 318
1478 }
1479 }
1480 }"#;
1481 let resp: NewhouseBaseInfoResponse = serde_json::from_str(json).unwrap();
1482 assert_eq!(resp.status, 1);
1483 let h: NewhouseHousing = resp.data.unwrap().housing.unwrap();
1484 assert_eq!(h.hid, 138145);
1485 assert_eq!(h.build_name, "春風大院");
1486 assert_eq!(h.price.pending, 1);
1487 assert_eq!(h.price.price, "待定");
1488 assert_eq!(h.area.area, "16~59");
1489 assert_eq!(h.area.area_min, "16.00");
1490 assert_eq!(h.layout.layout, "2/3/4");
1491 assert_eq!(h.tag, vec!["近捷運", "低公設", "景觀宅"]);
1492 assert_eq!(h.deal_time.kind, "finished");
1493 }
1494
1495 #[test]
1496 fn test_newhouse_base_info_tolerates_null_numerics() {
1497 use crate::types::NewhouseBaseInfoResponse;
1501 let json = r#"{
1502 "status": 1,
1503 "data": {
1504 "housing": {
1505 "hid": 1, "build_name": "x", "address": "x",
1506 "regionid": 1, "sectionid": 1,
1507 "community_id": null,
1508 "build_type_name": "預售屋", "purpose_name": "住宅",
1509 "price": {"pending": 1, "price": "待定", "unit": ""},
1510 "area": {"pending": 0, "area": "1", "unit": "坪"},
1511 "layout": {"pending": 0, "layout": "1", "unit": "房"},
1512 "households": "x",
1513 "manage_cost": {"pending": 0, "price": "0", "unit": "x"},
1514 "cover": "x", "shop_name": "x", "tag": [],
1515 "open_sell_time": null,
1516 "deal_time": {"type": "x", "date": "x", "deal": 0},
1517 "build_company": "x", "sell_company": "x",
1518 "structural_engine": "x", "floor": "x", "decorate": "x",
1519 "park_ratio": "x", "license": "x", "use_license": "x",
1520 "browsenum": 0, "fav_num": 0
1521 }
1522 }
1523 }"#;
1524 let resp: NewhouseBaseInfoResponse = serde_json::from_str(json).unwrap();
1525 let h = resp.data.unwrap().housing.unwrap();
1526 assert_eq!(h.community_id, 0);
1527 assert_eq!(h.open_sell_time, 0);
1528 }
1529
1530 #[test]
1531 fn test_newhouse_module_info_deserialize() {
1532 use crate::types::NewhouseModuleInfoResponse;
1533 let json = r#"{
1534 "status": 1,
1535 "data": {
1536 "layout": {"total": 0, "items": [], "room_group": []},
1537 "market": {
1538 "housing_id": 138145,
1539 "housing_name": "春風大院",
1540 "community_id": null,
1541 "rooms": [{"name": "成交均價", "price": "149.1"}],
1542 "items": [],
1543 "total": null,
1544 "update_date": "04/21"
1545 },
1546 "sales": {
1547 "data": [{
1548 "user_id": 1, "realname": "x", "mobile_v2": "0900",
1549 "avatar": "https://x", "tags": []
1550 }]
1551 }
1552 }
1553 }"#;
1554 let resp: NewhouseModuleInfoResponse = serde_json::from_str(json).unwrap();
1555 assert_eq!(resp.status, 1);
1556 let data = resp.data.unwrap();
1557 let market = data.market.unwrap();
1558 assert_eq!(market.total, 0);
1560 assert_eq!(market.community_id, 0);
1561 assert_eq!(market.rooms.len(), 1);
1562 assert_eq!(data.sales.data.len(), 1);
1563 }
1564
1565 #[test]
1566 fn test_newhouse_photos_deserialize() {
1567 use crate::types::NewhousePhotosResponse;
1568 let json = r#"{
1569 "status": 1,
1570 "data": [
1571 {
1572 "id": "logo", "name": "封面圖", "build_name": "x", "total": 1,
1573 "items": [{
1574 "id": 1, "cate": "logo", "cate_name": "封面圖",
1575 "src_img": "https://x.jpg"
1576 }]
1577 },
1578 {
1579 "id": "circum", "name": "環境圖", "build_name": "x", "total": 2,
1580 "items": [{
1581 "id": 2, "cate": "circum", "cate_name": "環境圖",
1582 "note": "捷運中山", "src_img": "https://y.jpg"
1583 }]
1584 }
1585 ]
1586 }"#;
1587 let resp: NewhousePhotosResponse = serde_json::from_str(json).unwrap();
1588 let cats = resp.data.unwrap();
1589 assert_eq!(cats.len(), 2);
1590 assert_eq!(cats[0].id, "logo");
1591 assert_eq!(cats[1].id, "circum");
1592 assert_eq!(cats[1].items[0].note, "捷運中山");
1593 }
1594
1595 #[test]
1596 fn test_newhouse_surrounding_deserialize() {
1597 use crate::types::NewhouseSurroundingResponse;
1598 let json = r#"{
1599 "status": 1,
1600 "data": {
1601 "facility": {
1602 "total": null,
1603 "traffic": [{
1604 "name": "中山國中",
1605 "distance": 876,
1606 "distance_text": "876公尺",
1607 "lat": 25.0521,
1608 "lng": 121.5488,
1609 "sub_type": "subway_station"
1610 }],
1611 "education": [],
1612 "life": []
1613 },
1614 "housing": {
1615 "hid": 1, "build_name": "x", "address": "x",
1616 "map": {"pending": 0, "lat": "25.05", "lng": "121.54"},
1617 "reception_map": {"pending": 0, "lat": "25.05", "lng": "121.54"}
1618 }
1619 }
1620 }"#;
1621 let resp: NewhouseSurroundingResponse = serde_json::from_str(json).unwrap();
1622 let s = resp.data.unwrap();
1623 assert_eq!(s.facility.total, 0);
1625 assert_eq!(s.facility.traffic[0].sub_type, "subway_station");
1627 assert_eq!(s.housing.map.lat, "25.05");
1629 }
1630
1631 #[test]
1632 fn test_newhouse_nearby_market_deserialize() {
1633 use crate::types::NewhouseNearbyMarketResponse;
1634 let json = r#"{
1635 "status": 1,
1636 "data": {
1637 "community_items": [{
1638 "community_id": 5880697, "community_name": "南京阿曼",
1639 "deal_count": 67,
1640 "price": {"content": "142.7", "unit": "萬/坪"},
1641 "community_image": "https://x",
1642 "build_type": 1, "build_type_str": "預售屋",
1643 "build_purpose": "住宅",
1644 "layout": {"content": "1、2", "unit": "房"},
1645 "area": {"content": "13~25", "unit": "坪"},
1646 "age": null,
1647 "distance": 788
1648 }],
1649 "business_items": [{
1650 "id": 101, "shop_id": 101, "name": "南京復興生活圈",
1651 "price_unit": "165.0", "unit": "萬/坪"
1652 }]
1653 }
1654 }"#;
1655 let resp: NewhouseNearbyMarketResponse = serde_json::from_str(json).unwrap();
1656 let m = resp.data.unwrap();
1657 assert_eq!(m.community_items.len(), 1);
1658 assert_eq!(m.community_items[0].age, 0);
1660 assert_eq!(m.community_items[0].deal_count, 67);
1661 assert_eq!(m.business_items.len(), 1);
1662 }
1663
1664 #[test]
1665 fn test_newhouse_price_list_deserialize() {
1666 use crate::types::NewhousePriceListResponse;
1667 let json = r#"{
1668 "status": 1,
1669 "data": {
1670 "housing_id": 138145, "housing_name": "春風大院",
1671 "community_id": null,
1672 "rooms": [{"name": "成交均價", "price": ""}],
1673 "has_sale_ctrl": 1,
1674 "sale_ctrl_info": {
1675 "update_count": 3,
1676 "price": {
1677 "id": 1, "address": "A棟7樓09戶", "room": "2房",
1678 "unit_price": {"price": "163.0", "unit": "萬"}
1679 }
1680 },
1681 "items": [],
1682 "total": null,
1683 "update_date": "04/21"
1684 }
1685 }"#;
1686 let resp: NewhousePriceListResponse = serde_json::from_str(json).unwrap();
1687 let p = resp.data.unwrap();
1688 assert_eq!(p.community_id, 0);
1690 assert_eq!(p.total, 0);
1691 let s = p.sale_ctrl_info.unwrap();
1692 assert_eq!(s.price.address, "A棟7樓09戶");
1693 }
1694
1695 #[test]
1696 fn test_coordinate_area_response_hit_shape() {
1697 use crate::types::{CoordArea, CoordResponse};
1699 let json = r#"{
1700 "status":1, "msg":"",
1701 "data":{"area":{"region_id":1,"region_name":"台北市","section_id":7,"section_name":"信義區"}}
1702 }"#;
1703 let resp: CoordResponse = serde_json::from_str(json).unwrap();
1704 assert_eq!(resp.status, 1);
1705 let area_v = resp.data.get("area").cloned().unwrap();
1706 let area: CoordArea = serde_json::from_value(area_v).unwrap();
1707 assert_eq!(area.region_id, 1);
1708 assert_eq!(area.region_name, "台北市");
1709 assert_eq!(area.section_id, 7);
1710 assert_eq!(area.section_name, "信義區");
1711 }
1712
1713 #[test]
1714 fn test_coordinate_area_response_miss_shape() {
1715 use crate::types::CoordResponse;
1719 let json = r#"{"status":0,"msg":"坐标不在台湾范围内","data":[]}"#;
1720 let resp: CoordResponse = serde_json::from_str(json).unwrap();
1721 assert_eq!(resp.status, 0);
1722 assert!(resp.msg.contains("台湾"));
1723 assert!(resp.data.is_array());
1725 }
1726
1727 #[test]
1728 fn test_coordinate_area_response_unknown_status_deserializes() {
1729 use crate::types::CoordResponse;
1734 let json = r#"{"status":2,"msg":"rate limited","data":null}"#;
1735 let resp: CoordResponse = serde_json::from_str(json).unwrap();
1736 assert_eq!(resp.status, 2);
1737 assert_eq!(resp.msg, "rate limited");
1738 assert!(resp.data.is_null());
1739 }
1740
1741 #[test]
1742 fn test_rent_detail_response_deserialize() {
1743 use crate::types::RentDetailResponse;
1747 let json = r#"{
1748 "status": 1,
1749 "msg": "",
1750 "data": {
1751 "title": "中山套房",
1752 "price": "17,800",
1753 "priceUnit": "元/月",
1754 "deposit": "押金面議",
1755 "headInfo": "17,800元/月",
1756 "address": {
1757 "data": "中山區雙城街50號",
1758 "value": "中山區雙城街50號 中山區雙城街50號",
1759 "lat": "25.0669894",
1760 "lng": "121.5235794"
1761 },
1762 "regionId": 1,
1763 "sectionId": 3,
1764 "kind": 2,
1765 "status": "open",
1766 "info": [
1767 {"name": "類型", "value": "獨立套房", "key": "kind"},
1768 {"name": "使用坪數", "value": "10.2坪", "key": "area"}
1769 ],
1770 "cost": {
1771 "title": "費用詳情",
1772 "active": 1,
1773 "data": [
1774 {"name": "押金", "value": "面議", "key": "deposit"}
1775 ]
1776 },
1777 "houseInfo": {"active": 1, "data": []},
1778 "preference": {"active": 1, "data": []},
1779 "service": {
1780 "title": "提供設備",
1781 "active": 1,
1782 "facility": [
1783 {"key": "fridge", "active": 1, "name": "冰箱"},
1784 {"key": "tv", "active": 0, "name": "電視"}
1785 ],
1786 "notice": [
1787 {"key": "leaseTime", "name": "最短一年"},
1788 {"key": "pet", "name": "不可養寵物"}
1789 ]
1790 },
1791 "surround": {
1792 "title": "周邊配套", "key": "surround",
1793 "address": "中山區雙城街50號",
1794 "lat": "25.0669894", "lng": "121.5235794",
1795 "data": [{"name": "交通", "key": "traffic", "children": [
1796 {"type": "subway", "name": "中山國小站", "distance": 558, "distanceTxt": "距房屋約558公尺"}
1797 ]}]
1798 },
1799 "tags": [{"id": 16, "value": "新上架"}],
1800 "publish": {
1801 "id": 2, "name": "新發佈", "key": "new",
1802 "postTime": "13小時前", "updateTime": "19分鐘內"
1803 },
1804 "remark": {
1805 "title": "屋況介紹",
1806 "key": "remark",
1807 "active": 1,
1808 "content": "屋況良好"
1809 },
1810 "linkInfo": {
1811 "name": "程先生", "role": 3, "roleName": "仲介",
1812 "mobile": "0922-168-660", "phone": "",
1813 "imName": "程先生", "imUid": 780619, "uid": 780619,
1814 "shopId": 4929, "isAgent": 0, "isGoldAgent": 0,
1815 "certificateStatus": 2, "rentNum": 5, "saleNum": 0
1816 }
1817 }
1818 }"#;
1819 let resp: RentDetailResponse = serde_json::from_str(json).unwrap();
1820 let d = resp.data.unwrap();
1821 assert_eq!(d.title, "中山套房");
1822 assert_eq!(d.price, "17,800");
1823 assert_eq!(d.price_unit, "元/月");
1824 assert_eq!(d.address.lat, "25.0669894");
1825 assert_eq!(d.region_id, 1);
1826 assert_eq!(d.kind, 2);
1827 assert_eq!(d.info.len(), 2);
1828 assert_eq!(d.info[0].key, "kind");
1829 assert_eq!(d.cost.data[0].value, "面議");
1830 assert_eq!(d.service.facility.len(), 2);
1832 assert_eq!(d.service.facility[0].key, "fridge");
1833 assert_eq!(d.service.facility[0].active, 1);
1834 assert_eq!(d.service.notice.len(), 2);
1835 assert_eq!(d.service.notice[0].key, "leaseTime");
1836 assert_eq!(d.surround.data[0].children[0].kind, "subway");
1837 assert_eq!(
1838 d.surround.data[0].children[0].distance_txt,
1839 "距房屋約558公尺"
1840 );
1841 assert_eq!(d.tags[0].id, 16);
1842 assert_eq!(d.publish.post_time, "13小時前");
1843 use crate::types::RentLinkInfoExt;
1845 assert_eq!(d.link_info.link_str("roleName"), Some("仲介"));
1846 assert_eq!(d.link_info.link_str("mobile"), Some("0922-168-660"));
1847 assert_eq!(d.link_info.link_u64("shopId"), Some(4929));
1848 }
1849
1850 #[test]
1851 fn test_rent_link_info_polymorphism() {
1852 use crate::types::{RentLinkInfo, RentLinkInfoExt};
1856
1857 let mapped: RentLinkInfo = serde_json::from_str(
1858 r#"{"name":"程先生","role":3,"roleName":"仲介","mobile":"0922","shopId":4929}"#,
1859 )
1860 .unwrap();
1861 assert_eq!(mapped.link_str("name"), Some("程先生"));
1862 assert_eq!(mapped.link_u64("shopId"), Some(4929));
1863 assert_eq!(mapped.link_u32("role"), Some(3));
1864
1865 let pairs: RentLinkInfo = serde_json::from_str(
1866 r#"[{"key":"name","value":"程先生"},{"key":"role","value":3},{"key":"shopId","value":4929}]"#,
1867 )
1868 .unwrap();
1869 assert_eq!(pairs.link_str("name"), Some("程先生"));
1870 assert_eq!(pairs.link_u64("shopId"), Some(4929));
1871 assert_eq!(pairs.link_u32("role"), Some(3));
1872 }
1873
1874 #[test]
1875 fn test_rent_photos_response_deserialize() {
1876 use crate::types::RentPhotosResponse;
1877 let json = r#"{
1878 "status": 1,
1879 "msg": "",
1880 "data": {
1881 "list": [{
1882 "key": "picture",
1883 "items": [{
1884 "photoId": 476004995,
1885 "photo": "https://img/big.jpg",
1886 "origPhoto": "https://img/orig.jpg",
1887 "thumbPhoto": "https://img/thumb.jpg",
1888 "isCover": 1,
1889 "purpose": 10,
1890 "note": "",
1891 "type": 3
1892 }]
1893 }]
1894 }
1895 }"#;
1896 let resp: RentPhotosResponse = serde_json::from_str(json).unwrap();
1897 let groups = resp.data.unwrap().list;
1898 assert_eq!(groups.len(), 1);
1899 assert_eq!(groups[0].key, "picture");
1900 assert_eq!(groups[0].items[0].photo_id, 476004995);
1901 assert_eq!(groups[0].items[0].is_cover, 1);
1902 }
1903
1904 #[test]
1905 fn test_sale_detail_response_deserialize() {
1906 use crate::types::SaleDetailResponse;
1909 let json = r#"{
1910 "status": 1,
1911 "msg": null,
1912 "data": {
1913 "id": "S19599759",
1914 "title": "三重透天",
1915 "price": "1,988萬元",
1916 "price_value": "1988",
1917 "unitprice": "86.02萬/坪",
1918 "area": "23.111坪",
1919 "area_value": "23.111",
1920 "layout": "3房2廳3衛",
1921 "room": "3",
1922 "hall": "2",
1923 "toilet": "3",
1924 "kind": "住宅",
1925 "kind_id": "9",
1926 "region": "新北市",
1927 "region_id": "3",
1928 "section": "三重區",
1929 "section_id": "43",
1930 "addr": "",
1931 "lat": "25.0713174",
1932 "lng": "121.4833166",
1933 "age": "56年",
1934 "houseage": "56",
1935 "shape": "透天厝",
1936 "fitment": "中檔裝潢",
1937 "direction": "東南",
1938 "lift": "0",
1939 "parking": "無",
1940 "mainarea": "20.69坪",
1941 "managefee": "無",
1942 "posttime": "1769328229",
1943 "community": "",
1944 "community_id": "",
1945 "linkman": "值班人員",
1946 "mobile": "0965-109-089",
1947 "telephone": "02-85229096",
1948 "email": "x@x.com",
1949 "identity": "仲介",
1950 "company_name": "有巢氏房屋",
1951 "certificate_type": "Middleman"
1952 }
1953 }"#;
1954 let resp: SaleDetailResponse = serde_json::from_str(json).unwrap();
1955 let d = resp.data.unwrap();
1956 assert_eq!(d.id, "S19599759");
1957 assert_eq!(d.price, "1,988萬元");
1958 assert_eq!(d.price_value, "1988");
1959 assert_eq!(d.region_id, "3");
1960 assert_eq!(d.layout, "3房2廳3衛");
1961 assert_eq!(d.identity, "仲介");
1962 assert_eq!(d.lat, "25.0713174");
1963 assert_eq!(d.lat.parse::<f64>().unwrap(), 25.0713174);
1965 assert_eq!(d.region_id.parse::<u32>().unwrap(), 3);
1966 }
1967
1968 #[test]
1969 fn test_sale_similar_wares_response_deserialize() {
1970 use crate::types::SaleSimilarWaresResponse;
1971 let json = r#"{
1972 "status": 1,
1973 "msg": "ok",
1974 "data": [{
1975 "type": "2",
1976 "post_id": "19979734",
1977 "title": "透天厝近三重",
1978 "price": "1980",
1979 "area": "20.808",
1980 "kind": "9",
1981 "is_vip": "0",
1982 "is_refresh": "0",
1983 "is_combine": "1",
1984 "room": "6房6衛",
1985 "section_name": "三重區",
1986 "photo_url": "https://img1.591.com.tw/x.jpg",
1987 "tag": "",
1988 "similar_type": ""
1989 }]
1990 }"#;
1991 let resp: SaleSimilarWaresResponse = serde_json::from_str(json).unwrap();
1992 assert_eq!(resp.data.len(), 1);
1993 assert_eq!(resp.data[0].post_id, "19979734");
1994 assert_eq!(resp.data[0].kind_type, "2");
1995 assert_eq!(resp.data[0].section_name, "三重區");
1996 assert_eq!(resp.data[0].is_combine, "1");
1997 }
1998
1999 #[test]
2000 fn test_high_value_search_response_deserialize() {
2001 use crate::types::HighValueSearchResponse;
2002 let json = r#"{
2003 "status": 1,
2004 "msg": "",
2005 "data": [{
2006 "type": 2,
2007 "post_id": 19916382,
2008 "title": "郵政新村好3房",
2009 "price": 3088,
2010 "area": 27.1,
2011 "kind": 9,
2012 "room": 3,
2013 "hall": 2,
2014 "toilet": 1,
2015 "region_name": "台北市",
2016 "section_name": "大安區",
2017 "street_name": "建國南路一段",
2018 "unit_price": 114.1,
2019 "cover": "https://img1.591.com.tw/x.jpg",
2020 "unit": "萬",
2021 "area_unit": "坪",
2022 "layout": "3房2廳1衛"
2023 }]
2024 }"#;
2025 let resp: HighValueSearchResponse = serde_json::from_str(json).unwrap();
2026 assert_eq!(resp.status, 1);
2027 assert_eq!(resp.data.len(), 1);
2028 let l = &resp.data[0];
2029 assert_eq!(l.post_id, 19916382);
2030 assert_eq!(l.title, "郵政新村好3房");
2031 assert_eq!(l.price, 3088);
2032 assert_eq!(l.region_name, "台北市");
2033 }
2034
2035 #[test]
2036 fn test_high_value_search_empty_data() {
2037 use crate::types::HighValueSearchResponse;
2040 let json = r#"{"status":1,"msg":"","data":[]}"#;
2041 let resp: HighValueSearchResponse = serde_json::from_str(json).unwrap();
2042 assert_eq!(resp.status, 1);
2043 assert!(resp.data.is_empty());
2044 }
2045
2046 #[test]
2047 fn test_high_value_params_serialize_omits_empty_arrays_correctly() {
2048 use crate::types::HighValueParams;
2049 let params = HighValueParams::for_region(1);
2053 let json = serde_json::to_string(¶ms).unwrap();
2054 assert!(json.contains("\"region_id\":1"));
2055 assert!(json.contains("\"kind\":9"));
2056 assert!(json.contains("\"type\":2"));
2058 assert!(json.contains("\"section_id\":[]"));
2059 assert!(json.contains("\"shape\":[]"));
2060 }
2061
2062 #[test]
2063 fn test_regions_list() {
2064 assert_eq!(REGIONS.len(), 22);
2065 assert_eq!(REGIONS[0].id, 1);
2066 assert_eq!(REGIONS[0].name, "台北市");
2067 assert_eq!(REGIONS[5].id, 6);
2068 assert_eq!(REGIONS[5].name, "高雄市");
2069 for (i, r) in REGIONS.iter().enumerate() {
2071 assert_eq!(r.id as usize, i + 1);
2072 }
2073 }
2074
2075 #[tokio::test]
2076 async fn test_hot_live() {
2077 let client = Client591::new().unwrap();
2078 let result = client.hot(1, 5).await;
2079 assert!(result.is_ok(), "hot() failed: {:?}", result);
2080 let communities = result.unwrap();
2081 assert!(!communities.is_empty());
2082 assert!(communities.len() <= 5);
2083 for c in &communities {
2084 assert!(!c.id.is_empty());
2085 assert!(!c.name.is_empty());
2086 }
2087 }
2088
2089 #[tokio::test]
2090 async fn test_community_live() {
2091 let client = Client591::new().unwrap();
2092 let result = client.community(7329).await;
2093 assert!(result.is_ok(), "community() failed: {:?}", result);
2094 let detail = result.unwrap().unwrap();
2095 assert_eq!(detail.id, 7329);
2096 assert!(detail.region.is_some());
2097 assert!(detail.address.is_some());
2098 }
2099
2100 #[tokio::test]
2101 async fn test_price_history_live() {
2102 let client = Client591::new().unwrap();
2103 let result = client.price_history(7329, 5).await;
2104 assert!(result.is_ok(), "price_history() failed: {:?}", result);
2105 let records = result.unwrap();
2106 assert!(records.len() <= 5);
2107 if !records.is_empty() {
2108 assert!(!records[0].date.is_empty());
2109 assert!(!records[0].layout.is_empty());
2110 assert!(!records[0].total_price.is_empty());
2111 }
2112 }
2113
2114 #[tokio::test]
2115 async fn test_sales_live() {
2116 let client = Client591::new().unwrap();
2117 let result = client.sales(7329, 5).await;
2118 assert!(result.is_ok(), "sales() failed: {:?}", result);
2119 let (total, listings) = result.unwrap();
2120 assert!(total > 0, "expected sale listings for 台北晶麒");
2121 assert!(listings.len() <= 5);
2122 if !listings.is_empty() {
2123 assert!(!listings[0].title.is_empty());
2124 assert!(!listings[0].price_v.price.is_empty());
2125 }
2126 }
2127
2128 #[tokio::test]
2129 async fn test_community_not_found() {
2130 let client = Client591::new().unwrap();
2131 let result = client.community(0).await;
2132 assert!(result.is_ok());
2133 assert!(result.unwrap().is_none());
2134 }
2135}