Skip to main content

tail_fin_591/
lib.rs

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
45/// Required headers/params for endpoints that BFF-validates a non-empty
46/// device ID. Originally the newhouse-detail family rejects with
47/// `{"status":0,"msg":"設備 ID 不能為空"}` if `deviceid` (header) is
48/// missing; the touch sale-detail endpoint does the same with the
49/// `device_id` query param. Verified live 2026-04-30 — the server
50/// doesn't validate the value's format, only its presence.
51const BFF_DEVICE_HEADER_VALUE: &str = "touch";
52const BFF_DEVICEID_VALUE: &str = "tail-fin-rust-client";
53
54/// Page size that `bff-house.591.com.tw/v3/web/rent/list` returns per
55/// request. Verified live 2026-04-30. Exposed publicly so the CLI's
56/// pagination math (checkpoint advance, resume offset, default limit)
57/// stays in sync with the library.
58pub const RENT_PAGE_SIZE: usize = 30;
59
60/// Threshold above which `warn_if_high_drop` flags an unusually high
61/// ad-/ placeholder-filter rate. 50% means "if more than half the raw
62/// items got dropped, something's probably wrong".
63const HIGH_DROP_RATIO: f64 = 0.5;
64
65/// Eprintln a warning when per-item filtering dropped more than
66/// `HIGH_DROP_RATIO` of the raw items 591 returned. Lets operators
67/// catch a 591-side schema change before the regression net does.
68fn 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
82/// Referer used for sale-side BFF endpoints.
83const SALE_REFERER: &str = "https://sale.591.com.tw/";
84/// Referer used for newhouse BFF endpoints.
85const NEWHOUSE_REFERER: &str = "https://newhouse.591.com.tw/";
86/// Referer used for cross-domain BFF endpoints (community-rank).
87const WWW_REFERER: &str = "https://www.591.com.tw/";
88
89/// Taiwan region codes used by the 591 API.
90pub 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
181/// Client for the 591 rental platform public API.
182///
183/// Uses native HTTP (reqwest) — no browser required.
184pub struct Client591 {
185    client: reqwest::Client,
186}
187
188impl Client591 {
189    /// Create a new client.
190    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    /// Fetch the hot community list for a given region.
211    ///
212    /// `region_id` is the 591 region code (1 = Taipei City).
213    /// Returns up to `limit` communities (the API ignores the limit param,
214    /// so slicing is done client-side).
215    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    /// Fetch detailed info for a community by ID.
240    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    /// Fetch transaction price history for a community.
246    ///
247    /// Returns recent actual sale prices recorded in the ROC government registry.
248    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    /// Fetch nearby communities (geographically close to `id`).
263    ///
264    /// 591 returns up to ~5 communities with sale-side stats (price
265    /// per ping, min sale price, distance). Useful for "what else is
266    /// in the same neighbourhood" queries; orthogonal to the rent-hot
267    /// list (which is region-keyed, not community-keyed).
268    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    /// Paginated **sale listings** for a region.
302    ///
303    /// Hits `bff-house.591.com.tw/v1/web/sale/list`. 591 paginates by
304    /// `firstRow` (0-indexed offset, page size 30). The total in
305    /// the returned `SaleHousePage` is the count of all matching
306    /// listings across pages.
307    ///
308    /// `region_id` is the same scheme as `hot()` (1 = Taipei).
309    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", &region_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        // 591 mixes paid newhouse ads (`is_newhouse: 1`, different
342        // schema) into the sale-listing array. Per-item parse +
343        // filter_map drops ads — callers get only listings that
344        // match the documented `SaleHouseListing` shape. We log to
345        // stderr when the drop ratio is high so a 591-side schema
346        // change doesn't silently degrade to empty pages.
347        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    /// Paginated **new-construction (newhouse) projects** for a region.
363    ///
364    /// Hits `bff-newhouse.591.com.tw/v1/list-search`. Pagination uses
365    /// `page` (1-indexed); 591 returns 20 per page.
366    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", &region_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        // Filter out empty placeholder slots (591 mixes ad-injection
397        // wrappers into the items array — these are objects with a
398        // single key and no `hid` / `build_name`).
399        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    /// Newhouse-detail GET request builder. The newhouse-detail
417    /// family rejects requests without `device: touch` and
418    /// `deviceid: <non-empty>` headers, so funnel every endpoint
419    /// through this helper to keep auth + Origin/Referer consistent.
420    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    /// Newhouse project detail core fields (build name, address, price,
430    /// area, room layouts, build type, manager fees, dates, licenses, …).
431    ///
432    /// Hits `bff-newhouse.591.com.tw/v1/detail/base-info`. Returns the
433    /// curated [`NewhouseHousing`] subset of the wire `data.housing`
434    /// block (80+ fields → ~25 most useful for project shopping).
435    /// `hid` is the project ID — same as `NewhouseProject.hid` from
436    /// `newhouse_list`.
437    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    /// Newhouse project module-info: floor plans, market history,
465    /// listed sales agents.
466    ///
467    /// Hits `bff-newhouse.591.com.tw/v1/detail/module-info`. Returns
468    /// the curated [`NewhouseModules`] aggregate of `layout`,
469    /// `market`, and `sales`. The `news` and `report` wire keys are
470    /// dropped (mostly empty across the projects we sampled).
471    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    /// Newhouse project photo gallery, organized by category.
504    ///
505    /// Hits `bff-newhouse.591.com.tw/v1/detail/photos`. Returns a
506    /// Vec of [`NewhousePhotoCategory`] (cover / floor plan / traffic
507    /// / 3D / real-life / environment) — empty Vec if the project has
508    /// no photos uploaded.
509    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    /// Newhouse project's surrounding POIs (transit, schools, life
534    /// amenities) plus building/sales-office geo coordinates.
535    ///
536    /// Hits `bff-newhouse.591.com.tw/v1/detail/surrounding`.
537    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    /// Newhouse project's nearby resale comps (other communities) and
563    /// business districts (商圈) with average prices.
564    ///
565    /// Hits `bff-newhouse.591.com.tw/v1/detail/nearby-market`. Note
566    /// the URL param is `hid` (not `id`) on this endpoint — separate
567    /// query semantics from the `id`-keyed siblings.
568    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    /// Newhouse project price-list (per-unit catalogue + sale-control
596    /// metadata).
597    ///
598    /// Hits `bff-newhouse.591.com.tw/v1/price/list` with `trans_type`
599    /// fixed at 1 (sale price; trans_type=2 returns rental-side data
600    /// for the same projects, not exercised here). Often less rich
601    /// than `newhouse_module_info().market` for active pre-sale
602    /// projects — call both and prefer module-info's market block
603    /// for actual transaction history.
604    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    /// Fetch the full newhouse-detail bundle in parallel.
633    ///
634    /// Calls all 6 detail sub-endpoints (`base-info`, `module-info`,
635    /// `photos`, `surrounding`, `nearby-market`, `price/list`)
636    /// concurrently via `tokio::join!`. Each sub-call's success or
637    /// failure lands independently in the corresponding bundle field
638    /// — partial failures don't fail the whole bundle. Returns once
639    /// all 6 calls have settled.
640    ///
641    /// Typical wall-clock: ~350ms (the slowest sub-call dominates) —
642    /// roughly 4× faster than calling the 6 atomic methods serially.
643    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    /// Two community rankings for a region — by price metric and by
693    /// sale-activity metric. Hits
694    /// `bff.591.com.tw/v1/community/community-rank`. Each slot holds
695    /// up to ~10 communities; both share the same `time` snapshot.
696    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        // The two slots come with separate `time` strings; use the
719        // price-side time as canonical (they're observed identical).
720        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    /// Pure-HTTP rental listing search via `bff-house.591.com.tw/v3/web/rent/list`.
728    ///
729    /// **No browser, no CSRF, no cookies required** — verified
730    /// 2026-04-30 against a clean curl from a fresh process. See
731    /// `docs/superpowers/research/2026-04-30-591-har-discovery.md`.
732    ///
733    /// Pagination is by `params.first_row` (page size 30). The server
734    /// echoes `firstRow: 0` even on later pages; trust your input
735    /// offset, not the response field.
736    ///
737    /// Returns `(total_matching, listings)` where `total_matching` is
738    /// the global count across all pages (NOT this page's length).
739    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) = &params.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    /// Crawl all rental listings matching `params`, paginating
792    /// automatically via `/v3/web/rent/list`.
793    ///
794    /// Calls `on_page(page_num, first_row, listings)` after each page.
795    /// Stops when a page returns fewer than `RENT_PAGE_SIZE` items
796    /// (last page reached) or after `opts.max_pages` pages if non-zero.
797    ///
798    /// Returns the total number of listings the callback received.
799    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    /// Curated premium-listing search.
872    ///
873    /// POSTs JSON to `bff-house.591.com.tw/v1/high-value/search`.
874    /// 591 returns a small (~6 items) hand-curated pool of premium
875    /// sale listings; the request-side `kind`/`type`/`section_id`/
876    /// `shape`/`room`/`price`/`area` filters are "preferred" rather
877    /// than strict (591's curation logic decides the final set).
878    ///
879    /// **Two kind values populate distinct curated pools**: `9` (the
880    /// default, mostly residential) and `10` (a separate bucket with
881    /// different streets / `post_id`s). Verified live 2026-04-30 — see
882    /// [`HighValueParams`] for the full quirk catalog. Use
883    /// [`HighValueParams::for_region`] for the typical defaults
884    /// (kind=9, type=2).
885    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    /// Reverse-geocode GPS coordinates to a 591 region+section.
912    ///
913    /// Hits `bff.591.com.tw/v1/coordinate/area`. Returns `Some(area)`
914    /// when the coordinates land inside Taiwan, `None` otherwise (591
915    /// responds with `status: 0` and the message
916    /// `"坐标不在台湾范围内"`).
917    ///
918    /// `region_id` is sent as a hint but doesn't constrain the
919    /// response — the server resolves the actual region/section from
920    /// the lat/lng. Pass any valid region (1 = Taipei is a safe
921    /// default).
922    ///
923    /// **Status-handling differs from sibling endpoints.** Most
924    /// `Client591` methods collapse non-1 status into either an
925    /// error or `Ok(None)`; this method instead matches `1` →
926    /// `Ok(Some(_))`, `0` → `Ok(None)` (documented miss), anything
927    /// else → `Err`. The intent: a future server-side wire-state
928    /// change becomes loud-fail rather than silently swallowed.
929    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        // status semantics: 1 = hit (data is {area: …}), 0 = miss
953        // (data is `[]`, msg explains why). Anything else is an
954        // unexpected wire-state — surface the msg as a real error so
955        // a future 591 schema flip doesn't silently swallow into None.
956        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        // Hit shape: `data: { area: { region_id, region_name, ... } }`.
967        // Pluck `data.area` from the raw Value and typed-deserialize.
968        // On status=1, data should be an object containing `area`.
969        // If it's anything else (e.g. an array — the off-Taiwan shape
970        // — or a missing key), surface a shape-aware error rather
971        // than a misleading "missing key" so a future server-side
972        // wire change is debuggable.
973        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    /// Single rent listing detail.
991    ///
992    /// Hits `bff-house.591.com.tw/v2/web/rent/detail`. Returns the
993    /// curated [`RentDetail`] subset of the wire `data` block — full
994    /// address with lat/lng, deposit + cost breakdown, structured
995    /// houseInfo / preference / service / surround sections, plus
996    /// the listing's primary contact ([`RentLinkInfo`]).
997    ///
998    /// `post_id` is the rent listing ID (`SearchListing.post_id` from
999    /// `rent_search`). Pure HTTP — no browser, no auth.
1000    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    /// Categorized photo gallery for a rent listing.
1023    ///
1024    /// Hits `bff-house.591.com.tw/v1/ware/photos`. Returns a Vec of
1025    /// [`RentPhotoGroup`] (photo buckets like `"picture"`, `"floor"`,
1026    /// `"environment"`); empty Vec if the listing has no photos.
1027    /// `type=1` is hardcoded on the wire — it's the rent-side photo
1028    /// query mode (verified live 2026-04-30).
1029    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    /// Single sale listing detail.
1051    ///
1052    /// Hits `bff-house.591.com.tw/v1/touch/sale/detail`. Returns the
1053    /// curated [`SaleDetail`] subset of the wire `data` block —
1054    /// title, price (raw + numeric), area / layout / floor / shape,
1055    /// lat / lng, age, fitment, agent contact (`linkman`, `mobile`,
1056    /// `telephone`, `email`, `identity`, `company_name`).
1057    ///
1058    /// `post_id` is the bare numeric sale listing ID. The wire
1059    /// expects an `S`-prefixed form (`"S19599759"`) — the adapter
1060    /// adds the prefix for you. Uses `device_id=tail-fin-rust-client`
1061    /// and `device=touch` query params; verified live 2026-05-01 that
1062    /// the endpoint returns `status: 0` without a non-empty `device_id`.
1063    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    /// Similar sale listings — curated "you might also like" set.
1097    ///
1098    /// Hits `bff-house.591.com.tw/v2/web/sale/similar-wares`. Returns
1099    /// up to ~5 [`SaleSimilarWare`] entries (compact title / price /
1100    /// area / room / cover-photo URL) for cross-listing browse.
1101    /// Anonymous, no special headers needed.
1102    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    /// Fetch active sale listings near a community.
1134    ///
1135    /// Listings are flattened across all room types, sorted by post time (API order).
1136    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    // Shared detail fetch — one HTTP call, all sections.
1152    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// ---------------------------------------------------------------------------
1173// Tests
1174// ---------------------------------------------------------------------------
1175
1176#[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        // 591 sends `total` as a string today (`"3728"`), but the
1278        // sibling sale-list endpoint already returns ints. Lock the
1279        // forward-compat contract so a server-side type flip doesn't
1280        // silently land at 0 or fail loudly without explanation.
1281        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        // The pre-fix code did `.parse().unwrap_or(0)` which silently
1290        // collapsed garbage to 0. After the fix the deserializer
1291        // rejects non-numeric strings, surfacing as TailFinError::Parse
1292        // at the `.json()` call site rather than a phantom zero.
1293        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        // Pinning the null-tolerance contract for the I1 fix:
1498        // open_sell_time=null and community_id=null both land at 0
1499        // rather than failing the entire deserialize.
1500        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        // null total / null community_id collapse to 0.
1559        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        // facility.total: null lands at 0 via I1 fix.
1624        assert_eq!(s.facility.total, 0);
1625        // POIs use raw f64 lat/lng on this endpoint (different from housing.map).
1626        assert_eq!(s.facility.traffic[0].sub_type, "subway_station");
1627        // Project map uses string lat/lng (591 wire-asymmetry).
1628        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        // age: null collapses to 0 via I1 fix.
1659        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        // null community_id and total collapse to 0 via I1 fix.
1689        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        // Success path: status=1, data = { area: { ... } }.
1698        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        // Off-Taiwan path: status=0, data = [] (NOT an object).
1716        // Locks the wire-shape polymorphism — typed deserialize via
1717        // `data: serde_json::Value` survives both shapes.
1718        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        // data is the empty array — Value variant, not a parse error.
1724        assert!(resp.data.is_array());
1725    }
1726
1727    #[test]
1728    fn test_coordinate_area_response_unknown_status_deserializes() {
1729        // Pins the third arm of the status-handling matrix: any
1730        // status other than 0 or 1 should still deserialize cleanly
1731        // (the dispatch happens in coordinate_area, not in serde) so
1732        // that the production code can format msg into the error.
1733        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        // Compact happy-path fixture — all the camelCase wire keys
1744        // (priceUnit, headInfo, regionId, etc.) routed through
1745        // #[serde(rename_all = "camelCase")] on the structs.
1746        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        // service has the new typed shape (facility + notice).
1831        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        // link_info is now a polymorphic Value — query via accessor trait.
1844        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        // 591 returns linkInfo in two shapes interchangeably: named-
1853        // field object OR array of {key, value} pairs. Both must yield
1854        // identical accessor results — verified live 2026-04-30.
1855        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        // Compact fixture exercising the wire's stringly-typed numerics
1907        // (region_id, room, lat, etc. all arrive as strings).
1908        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        // Numeric parses cleanly from wire-string.
1964        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        // Some kinds (e.g. kind=2) return status=1 with data:[] — locked
2038        // here so a future "panic on empty data" regression is caught.
2039        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        // for_region defaults: empty arrays should still be serialized
2050        // (591 expects them present even when empty), kind_type renamed
2051        // to `type`. Verifying the wire-shape contract.
2052        let params = HighValueParams::for_region(1);
2053        let json = serde_json::to_string(&params).unwrap();
2054        assert!(json.contains("\"region_id\":1"));
2055        assert!(json.contains("\"kind\":9"));
2056        // type is a Rust keyword — verify the rename worked.
2057        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        // All IDs unique and sequential
2070        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}