pub mod site;
pub mod types;
pub use site::FiveNineOneSite;
pub use types::{
AreaRange, AreaValue, Community, CommunityDetail, CommunityRankItem, CommunityRanks,
ContentUnit, CoordArea, CrawlOptions, DealTime, HighValueListing, HighValueParams,
LabelledValue, MapCoord, NearbyCommunity, NewhouseDetail, NewhouseHousing, NewhouseLayoutBlock,
NewhouseMarket, NewhouseMarketItem, NewhouseMarketRoom, NewhouseModules,
NewhouseNearbyBusiness, NewhouseNearbyComm, NewhouseNearbyMarket, NewhousePage, NewhousePhoto,
NewhousePhotoCategory, NewhousePoi, NewhousePriceList, NewhouseProject, NewhouseSaleCtrlInfo,
NewhouseSaleCtrlPrice, NewhouseSalesAgent, NewhouseSurrounding, NewhouseSurroundingFacility,
NewhouseSurroundingHousing, PendingArea, PendingPrice, PendingRoom, PriceRange, PriceRecord,
PriceValue, Region, RentAddress, RentDetail, RentFactTable, RentLinkInfo, RentLinkInfoExt,
RentNoticeItem, RentPhoto, RentPhotoGroup, RentPublish, RentRemark, RentServiceItem,
RentServiceTable, RentSurround, RentSurroundCategory, RentSurroundPoi, RentTag, SaleDetail,
SaleHouseListing, SaleHousePage, SaleListing, SaleSimilarWare, SearchListing, SearchParams,
};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, REFERER};
use tail_fin_common::TailFinError;
use types::{DetailResponse, HotResponse};
const 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";
const HOT_URL: &str = "https://api.591.com.tw/api/community/rentHot";
const DETAIL_URL: &str = "https://api.591.com.tw/api/community/detail";
const NEARBY_URL: &str = "https://api.591.com.tw/api/community/nearby";
const SALE_LIST_URL: &str = "https://bff-house.591.com.tw/v1/web/sale/list";
const NEWHOUSE_LIST_URL: &str = "https://bff-newhouse.591.com.tw/v1/list-search";
const COMMUNITY_RANK_URL: &str = "https://bff.591.com.tw/v1/community/community-rank";
const RENT_LIST_URL: &str = "https://bff-house.591.com.tw/v3/web/rent/list";
const NEWHOUSE_BASE_INFO_URL: &str = "https://bff-newhouse.591.com.tw/v1/detail/base-info";
const NEWHOUSE_MODULE_INFO_URL: &str = "https://bff-newhouse.591.com.tw/v1/detail/module-info";
const NEWHOUSE_PHOTOS_URL: &str = "https://bff-newhouse.591.com.tw/v1/detail/photos";
const NEWHOUSE_SURROUNDING_URL: &str = "https://bff-newhouse.591.com.tw/v1/detail/surrounding";
const NEWHOUSE_NEARBY_MARKET_URL: &str = "https://bff-newhouse.591.com.tw/v1/detail/nearby-market";
const NEWHOUSE_PRICE_LIST_URL: &str = "https://bff-newhouse.591.com.tw/v1/price/list";
const HIGH_VALUE_SEARCH_URL: &str = "https://bff-house.591.com.tw/v1/high-value/search";
const COORDINATE_AREA_URL: &str = "https://bff.591.com.tw/v1/coordinate/area";
const RENT_DETAIL_URL: &str = "https://bff-house.591.com.tw/v2/web/rent/detail";
const RENT_PHOTOS_URL: &str = "https://bff-house.591.com.tw/v1/ware/photos";
const SALE_DETAIL_URL: &str = "https://bff-house.591.com.tw/v1/touch/sale/detail";
const SALE_SIMILAR_URL: &str = "https://bff-house.591.com.tw/v2/web/sale/similar-wares";
const BFF_DEVICE_HEADER_VALUE: &str = "touch";
const BFF_DEVICEID_VALUE: &str = "tail-fin-rust-client";
pub const RENT_PAGE_SIZE: usize = 30;
const HIGH_DROP_RATIO: f64 = 0.5;
fn warn_if_high_drop(endpoint: &str, raw_count: usize, kept: usize) {
if raw_count == 0 || kept >= raw_count {
return;
}
let dropped = raw_count - kept;
if (dropped as f64) / (raw_count as f64) > HIGH_DROP_RATIO {
eprintln!(
"[tail-fin-591] {endpoint}: filter dropped {dropped}/{raw_count} items \
(>{:.0}% — possible 591-side schema change)",
HIGH_DROP_RATIO * 100.0
);
}
}
const SALE_REFERER: &str = "https://sale.591.com.tw/";
const NEWHOUSE_REFERER: &str = "https://newhouse.591.com.tw/";
const WWW_REFERER: &str = "https://www.591.com.tw/";
pub const REGIONS: &[Region] = &[
Region {
id: 1,
name: "台北市",
},
Region {
id: 2,
name: "新北市",
},
Region {
id: 3,
name: "桃園市",
},
Region {
id: 4,
name: "台中市",
},
Region {
id: 5,
name: "台南市",
},
Region {
id: 6,
name: "高雄市",
},
Region {
id: 7,
name: "基隆市",
},
Region {
id: 8,
name: "新竹市",
},
Region {
id: 9,
name: "嘉義市",
},
Region {
id: 10,
name: "新竹縣",
},
Region {
id: 11,
name: "苗栗縣",
},
Region {
id: 12,
name: "彰化縣",
},
Region {
id: 13,
name: "南投縣",
},
Region {
id: 14,
name: "雲林縣",
},
Region {
id: 15,
name: "嘉義縣",
},
Region {
id: 16,
name: "屏東縣",
},
Region {
id: 17,
name: "宜蘭縣",
},
Region {
id: 18,
name: "花蓮縣",
},
Region {
id: 19,
name: "台東縣",
},
Region {
id: 20,
name: "澎湖縣",
},
Region {
id: 21,
name: "金門縣",
},
Region {
id: 22,
name: "連江縣",
},
];
pub struct Client591 {
client: reqwest::Client,
}
impl Client591 {
pub fn new() -> Result<Self, TailFinError> {
let mut headers = HeaderMap::new();
headers.insert(
ACCEPT,
HeaderValue::from_static("application/json, text/plain, */*"),
);
headers.insert(
REFERER,
HeaderValue::from_static("https://rent.591.com.tw/"),
);
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.default_headers(headers)
.build()
.map_err(|e| TailFinError::Api(e.to_string()))?;
Ok(Self { client })
}
pub async fn hot(&self, region_id: u32, limit: usize) -> Result<Vec<Community>, TailFinError> {
let resp: HotResponse = self
.client
.get(HOT_URL)
.query(&[("region_id", region_id.to_string())])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 API returned status {}",
resp.status
)));
}
let mut items = resp.data.unwrap_or_default();
items.truncate(limit);
Ok(items)
}
pub async fn community(&self, id: u64) -> Result<Option<CommunityDetail>, TailFinError> {
let resp = self.fetch_detail(id).await?;
Ok(resp.and_then(|d| d.community))
}
pub async fn price_history(
&self,
id: u64,
limit: usize,
) -> Result<Vec<PriceRecord>, TailFinError> {
let data = self.fetch_detail(id).await?;
let mut records = data
.and_then(|d| d.price)
.map(|p| p.items)
.unwrap_or_default();
records.truncate(limit);
Ok(records)
}
pub async fn nearby(
&self,
id: u64,
limit: usize,
) -> Result<Vec<NearbyCommunity>, TailFinError> {
let resp: types::NearbyResponse = self
.client
.get(NEARBY_URL)
.query(&[("id", id.to_string())])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 nearby returned status {}",
resp.status
)));
}
let mut items: Vec<NearbyCommunity> = resp
.data
.unwrap_or_default()
.into_iter()
.map(NearbyCommunity::from)
.collect();
items.truncate(limit);
Ok(items)
}
pub async fn sale_list(
&self,
region_id: u32,
first_row: usize,
limit: usize,
) -> Result<SaleHousePage, TailFinError> {
let resp: types::SaleListResponse = self
.client
.get(SALE_LIST_URL)
.header(REFERER, SALE_REFERER)
.query(&[
("type", "2"),
("category", "1"),
("regionid", ®ion_id.to_string()),
("firstRow", &first_row.to_string()),
("shType", "list"),
])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 sale_list returned status {}",
resp.status
)));
}
let data = resp.data.unwrap_or_default();
let raw_count = data.house_list.len();
let houses: Vec<SaleHouseListing> = data
.house_list
.into_iter()
.filter_map(|v| serde_json::from_value(v).ok())
.take(limit)
.collect();
warn_if_high_drop("sale_list", raw_count, houses.len());
Ok(SaleHousePage {
total: data.total,
first_row,
houses,
})
}
pub async fn newhouse_list(
&self,
region_id: u32,
page: u32,
) -> Result<NewhousePage, TailFinError> {
let resp: types::NewhouseListResponse = self
.client
.get(NEWHOUSE_LIST_URL)
.header(REFERER, NEWHOUSE_REFERER)
.query(&[
("page", &page.to_string()),
("device", &"pc".to_string()),
("regionid", ®ion_id.to_string()),
])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 newhouse_list returned status {}",
resp.status
)));
}
let data = resp
.data
.ok_or_else(|| TailFinError::Api("591 newhouse_list returned no data".into()))?;
let raw_count = data.items.len();
let items: Vec<NewhouseProject> = data
.items
.into_iter()
.filter_map(|v| serde_json::from_value(v).ok())
.collect();
warn_if_high_drop("newhouse_list", raw_count, items.len());
Ok(NewhousePage {
total: data.total,
online_total: data.online_total,
page: data.page,
per_page: data.per_page,
total_page: data.total_page,
items,
})
}
fn newhouse_get(&self, url: &str) -> reqwest::RequestBuilder {
self.client
.get(url)
.header(REFERER, NEWHOUSE_REFERER)
.header("Origin", "https://newhouse.591.com.tw")
.header("device", BFF_DEVICE_HEADER_VALUE)
.header("deviceid", BFF_DEVICEID_VALUE)
}
pub async fn newhouse_base_info(&self, hid: u64) -> Result<NewhouseHousing, TailFinError> {
let resp: types::NewhouseBaseInfoResponse = self
.newhouse_get(NEWHOUSE_BASE_INFO_URL)
.query(&[
("id", hid.to_string()),
("region_id", "1".to_string()),
("is_auth", "0".to_string()),
])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 newhouse_base_info returned status {} ({})",
resp.status,
resp.msg.as_deref().unwrap_or("")
)));
}
resp.data
.and_then(|d| d.housing)
.ok_or_else(|| TailFinError::Api(format!("591 newhouse {hid} not found")))
}
pub async fn newhouse_module_info(&self, hid: u64) -> Result<NewhouseModules, TailFinError> {
let resp: types::NewhouseModuleInfoResponse = self
.newhouse_get(NEWHOUSE_MODULE_INFO_URL)
.query(&[
("id", hid.to_string()),
("region_id", "1".to_string()),
("is_auth", "0".to_string()),
])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 newhouse_module_info returned status {} ({})",
resp.status,
resp.msg.as_deref().unwrap_or("")
)));
}
let data = resp
.data
.ok_or_else(|| TailFinError::Api(format!("591 newhouse {hid} not found")))?;
Ok(NewhouseModules {
layout: data.layout,
market: data.market,
sales_agents: data.sales.data,
})
}
pub async fn newhouse_photos(
&self,
hid: u64,
) -> Result<Vec<NewhousePhotoCategory>, TailFinError> {
let resp: types::NewhousePhotosResponse = self
.newhouse_get(NEWHOUSE_PHOTOS_URL)
.query(&[("id", hid.to_string()), ("is_auth", "0".to_string())])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 newhouse_photos returned status {} ({})",
resp.status,
resp.msg.as_deref().unwrap_or("")
)));
}
Ok(resp.data.unwrap_or_default())
}
pub async fn newhouse_surrounding(
&self,
hid: u64,
) -> Result<NewhouseSurrounding, TailFinError> {
let resp: types::NewhouseSurroundingResponse = self
.newhouse_get(NEWHOUSE_SURROUNDING_URL)
.query(&[("id", hid.to_string()), ("is_auth", "0".to_string())])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 newhouse_surrounding returned status {} ({})",
resp.status,
resp.msg.as_deref().unwrap_or("")
)));
}
resp.data
.ok_or_else(|| TailFinError::Api(format!("591 newhouse {hid} not found")))
}
pub async fn newhouse_nearby_market(
&self,
hid: u64,
) -> Result<NewhouseNearbyMarket, TailFinError> {
let resp: types::NewhouseNearbyMarketResponse = self
.newhouse_get(NEWHOUSE_NEARBY_MARKET_URL)
.query(&[("hid", hid.to_string())])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 newhouse_nearby_market returned status {} ({})",
resp.status,
resp.msg.as_deref().unwrap_or("")
)));
}
Ok(resp.data.unwrap_or(NewhouseNearbyMarket {
community_items: vec![],
business_items: vec![],
}))
}
pub async fn newhouse_price_list(&self, hid: u64) -> Result<NewhousePriceList, TailFinError> {
let resp: types::NewhousePriceListResponse = self
.newhouse_get(NEWHOUSE_PRICE_LIST_URL)
.query(&[
("id", hid.to_string()),
("region_id", "1".to_string()),
("trans_type", "1".to_string()),
("room", "0".to_string()),
("from", "detail".to_string()),
])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 newhouse_price_list returned status {} ({})",
resp.status,
resp.msg.as_deref().unwrap_or("")
)));
}
resp.data
.ok_or_else(|| TailFinError::Api(format!("591 newhouse {hid} not found")))
}
pub async fn newhouse_detail(&self, hid: u64) -> NewhouseDetail {
let (base, modules, photos, surround, nearby, price) = tokio::join!(
self.newhouse_base_info(hid),
self.newhouse_module_info(hid),
self.newhouse_photos(hid),
self.newhouse_surrounding(hid),
self.newhouse_nearby_market(hid),
self.newhouse_price_list(hid),
);
let (housing, housing_error) = match base {
Ok(v) => (Some(v), None),
Err(e) => (None, Some(e.to_string())),
};
let (modules, modules_error) = match modules {
Ok(v) => (Some(v), None),
Err(e) => (None, Some(e.to_string())),
};
let (photos_vec, photos_error) = match photos {
Ok(v) => (v, None),
Err(e) => (vec![], Some(e.to_string())),
};
let (surrounding, surrounding_error) = match surround {
Ok(v) => (Some(v), None),
Err(e) => (None, Some(e.to_string())),
};
let (nearby_market, nearby_market_error) = match nearby {
Ok(v) => (Some(v), None),
Err(e) => (None, Some(e.to_string())),
};
let (price_list, price_list_error) = match price {
Ok(v) => (Some(v), None),
Err(e) => (None, Some(e.to_string())),
};
NewhouseDetail {
housing,
housing_error,
modules,
modules_error,
photos: photos_vec,
photos_error,
surrounding,
surrounding_error,
nearby_market,
nearby_market_error,
price_list,
price_list_error,
}
}
pub async fn community_rank(&self, region_id: u32) -> Result<CommunityRanks, TailFinError> {
let resp: types::CommunityRankResponse = self
.client
.get(COMMUNITY_RANK_URL)
.header(REFERER, WWW_REFERER)
.query(&[("regionid", region_id.to_string())])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 community_rank returned status {}",
resp.status
)));
}
let data = resp
.data
.ok_or_else(|| TailFinError::Api("591 community_rank returned no data".into()))?;
Ok(CommunityRanks {
price_data: data.price_data.data,
sale_data: data.sale_data.data,
time: data.price_data.time,
})
}
pub async fn rent_search(
&self,
params: &SearchParams,
) -> Result<(u32, Vec<SearchListing>), TailFinError> {
let region = params.region_id.to_string();
let first_row = params.first_row.to_string();
let mut query: Vec<(&str, String)> = vec![("regionid", region), ("firstRow", first_row)];
if let Some(k) = params.kind {
query.push(("kind", k.to_string()));
}
if params.price_min.is_some() || params.price_max.is_some() {
let lo = params.price_min.map(|p| p.to_string()).unwrap_or_default();
let hi = params.price_max.map(|p| p.to_string()).unwrap_or_default();
query.push(("multiPrice", format!("{lo}_{hi}")));
}
if let Some(o) = ¶ms.order {
query.push(("order", o.clone()));
}
let resp: types::RentListResponse = self
.client
.get(RENT_LIST_URL)
.query(&query)
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 rent_search returned status {}",
resp.status
)));
}
let data = resp
.data
.ok_or_else(|| TailFinError::Api("591 rent_search returned no data".into()))?;
let total = data.total;
let limit = params.limit.max(1);
let listings: Vec<SearchListing> = data
.items
.into_iter()
.take(limit)
.map(SearchListing::from)
.collect();
Ok((total, listings))
}
pub async fn rent_crawl<F>(
&self,
params: &SearchParams,
opts: &CrawlOptions,
mut on_page: F,
) -> Result<usize, TailFinError>
where
F: FnMut(usize, usize, &[SearchListing]),
{
let mut total_fetched = 0;
let mut page = opts.start_page;
let mut pages_fetched = 0;
loop {
let first_row = page * RENT_PAGE_SIZE;
let page_params = SearchParams {
first_row,
limit: RENT_PAGE_SIZE,
..params.clone()
};
let listings = {
let mut last_err: Option<TailFinError> = None;
let mut result: Option<Vec<SearchListing>> = None;
for attempt in 0..=opts.retries {
match self.rent_search(&page_params).await {
Ok((_total, items)) => {
result = Some(items);
break;
}
Err(e) => {
if attempt < opts.retries {
eprintln!(
"[crawl] page {} attempt {}/{} failed: {}; retrying in 2s",
page + 1,
attempt + 1,
opts.retries,
e
);
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
last_err = Some(e);
}
}
}
match result {
Some(items) => items,
None => return Err(last_err.unwrap()),
}
};
let n = listings.len();
if n > 0 {
on_page(page, first_row, &listings);
total_fetched += n;
}
pages_fetched += 1;
let max_reached = opts.max_pages > 0 && pages_fetched >= opts.max_pages;
let last_page = n < RENT_PAGE_SIZE;
if last_page || max_reached {
break;
}
if opts.delay_ms > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(opts.delay_ms)).await;
}
page += 1;
}
Ok(total_fetched)
}
pub async fn high_value_search(
&self,
params: &HighValueParams,
) -> Result<Vec<HighValueListing>, TailFinError> {
let resp: types::HighValueSearchResponse = self
.client
.post(HIGH_VALUE_SEARCH_URL)
.header(REFERER, SALE_REFERER)
.header("Origin", "https://sale.591.com.tw")
.json(params)
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 high_value_search returned status {} ({})",
resp.status, resp.msg
)));
}
Ok(resp.data)
}
pub async fn coordinate_area(
&self,
latitude: f64,
longitude: f64,
region_id: u32,
) -> Result<Option<CoordArea>, TailFinError> {
let resp: types::CoordResponse = self
.client
.get(COORDINATE_AREA_URL)
.header(REFERER, NEWHOUSE_REFERER)
.query(&[
("latitude", latitude.to_string()),
("longitude", longitude.to_string()),
("region_id", region_id.to_string()),
("device", "touch".to_string()),
])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
match resp.status {
1 => {}
0 => return Ok(None),
other => {
return Err(TailFinError::Api(format!(
"591 coordinate_area returned status {other} ({})",
resp.msg
)));
}
}
let area_value = resp.data.get("area").cloned().ok_or_else(|| {
TailFinError::Parse(format!(
"unexpected data shape on hit (expected object with 'area' key, got {})",
if resp.data.is_array() {
"array"
} else if resp.data.is_object() {
"object without 'area' key"
} else {
"non-object"
}
))
})?;
let area: CoordArea =
serde_json::from_value(area_value).map_err(|e| TailFinError::Parse(e.to_string()))?;
Ok(Some(area))
}
pub async fn rent_detail(&self, post_id: u64) -> Result<RentDetail, TailFinError> {
let resp: types::RentDetailResponse = self
.client
.get(RENT_DETAIL_URL)
.query(&[("id", post_id.to_string())])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 rent_detail returned status {} ({})",
resp.status, resp.msg
)));
}
resp.data
.ok_or_else(|| TailFinError::Api(format!("591 rent listing {post_id} not found")))
}
pub async fn rent_photos(&self, post_id: u64) -> Result<Vec<RentPhotoGroup>, TailFinError> {
let resp: types::RentPhotosResponse = self
.client
.get(RENT_PHOTOS_URL)
.query(&[("id", post_id.to_string()), ("type", "1".to_string())])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 rent_photos returned status {} ({})",
resp.status, resp.msg
)));
}
Ok(resp.data.map(|d| d.list).unwrap_or_default())
}
pub async fn sale_detail(&self, post_id: u64) -> Result<SaleDetail, TailFinError> {
let id = format!("S{post_id}");
let resp: types::SaleDetailResponse = self
.client
.get(SALE_DETAIL_URL)
.header(REFERER, SALE_REFERER)
.header("Origin", "https://sale.591.com.tw")
.query(&[
("id", id.as_str()),
("is_business", "0"),
("device_id", BFF_DEVICEID_VALUE),
("__v__", "1"),
("region_id", "1"),
("device", "touch"),
])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 sale_detail returned status {} ({})",
resp.status,
resp.msg.as_deref().unwrap_or("")
)));
}
resp.data
.ok_or_else(|| TailFinError::Api(format!("591 sale listing {post_id} not found")))
}
pub async fn sale_similar_wares(
&self,
post_id: u64,
) -> Result<Vec<SaleSimilarWare>, TailFinError> {
let resp: types::SaleSimilarWaresResponse = self
.client
.get(SALE_SIMILAR_URL)
.header(REFERER, SALE_REFERER)
.header("Origin", "https://sale.591.com.tw")
.query(&[
("id", post_id.to_string()),
("region_id", "1".to_string()),
("device", "touch".to_string()),
])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Err(TailFinError::Api(format!(
"591 sale_similar_wares returned status {} ({})",
resp.status,
resp.msg.as_deref().unwrap_or("")
)));
}
Ok(resp.data)
}
pub async fn sales(
&self,
id: u64,
limit: usize,
) -> Result<(u32, Vec<SaleListing>), TailFinError> {
let data = self.fetch_detail(id).await?;
let sale = data.and_then(|d| d.sale);
let total = sale.as_ref().map(|s| s.total).unwrap_or(0);
let mut listings: Vec<SaleListing> = sale
.map(|s| s.rooms.into_iter().flat_map(|r| r.items).collect())
.unwrap_or_default();
listings.truncate(limit);
Ok((total, listings))
}
async fn fetch_detail(&self, id: u64) -> Result<Option<types::DetailData>, TailFinError> {
let resp: DetailResponse = self
.client
.get(DETAIL_URL)
.query(&[("id", id.to_string())])
.send()
.await
.map_err(|e| TailFinError::Api(e.to_string()))?
.json()
.await
.map_err(|e| TailFinError::Parse(e.to_string()))?;
if resp.status != 1 {
return Ok(None);
}
Ok(resp.data)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{DetailResponse, HotResponse};
#[test]
fn test_client_new() {
let client = Client591::new();
assert!(client.is_ok());
}
#[test]
fn test_rent_list_response_deserialize() {
use crate::types::RentListResponse;
let json = r#"{
"status": 1,
"data": {
"total": "3728",
"firstRow": 0,
"items": [{
"id": 21121520,
"type": 1,
"kind": 2,
"kind_name": "獨立套房",
"title": "中山套房",
"price": "17,500",
"price_unit": "元/月",
"address": "中山區-中山北路一段105巷",
"area_name": "9坪",
"layoutStr": "",
"floor_name": "4F/6F",
"photoList": ["https://img2.591.com.tw/a.jpg"],
"tags": ["近捷運"],
"refresh_time": "7小時內更新",
"regionid": 1,
"sectionid": 3
}]
}
}"#;
let resp: RentListResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 1);
let data = resp.data.unwrap();
assert_eq!(data.total, 3728);
assert_eq!(data.items.len(), 1);
assert_eq!(data.items[0].id, 21121520);
assert_eq!(data.items[0].title, "中山套房");
}
#[test]
fn test_rent_list_item_into_search_listing_full() {
use crate::types::RentListItem;
let item = RentListItem {
id: 21121520,
title: "中山套房".into(),
price: Some("17,500".into()),
price_unit: Some("元/月".into()),
address: Some("中山區-中山北路".into()),
area_name: Some("9坪".into()),
kind_name: Some("獨立套房".into()),
layout_str: Some("2房1廳".into()),
floor_name: Some("4F/6F".into()),
photo_list: Some(vec!["https://img2.591.com.tw/a.jpg".into()]),
tags: Some(vec!["近捷運".into()]),
refresh_time: Some("7小時內更新".into()),
};
let listing: SearchListing = item.into();
assert_eq!(listing.post_id, 21121520);
assert_eq!(listing.title, "中山套房");
assert_eq!(listing.price.as_deref(), Some("17,500"));
assert_eq!(listing.area.as_deref(), Some("9坪"));
assert_eq!(listing.room.as_deref(), Some("2房1廳"));
assert_eq!(listing.floor.as_deref(), Some("4F/6F"));
assert_eq!(listing.tags.as_ref().map(|v| v.len()), Some(1));
assert_eq!(listing.post_time.as_deref(), Some("7小時內更新"));
}
#[test]
fn test_rent_list_item_into_search_listing_empty_collections_become_none() {
use crate::types::RentListItem;
let item = RentListItem {
id: 1,
title: "x".into(),
price: None,
price_unit: None,
address: None,
area_name: None,
kind_name: None,
layout_str: Some(String::new()),
floor_name: None,
photo_list: Some(vec![]),
tags: Some(vec![]),
refresh_time: None,
};
let listing: SearchListing = item.into();
assert!(listing.room.is_none(), "empty layoutStr should become None");
assert!(listing.photo_list.is_none(), "empty Vec should become None");
assert!(listing.tags.is_none(), "empty Vec should become None");
}
#[test]
fn test_rent_list_response_total_accepts_int() {
use crate::types::RentListResponse;
let json = r#"{"status":1,"data":{"total":3728,"firstRow":0,"items":[]}}"#;
let resp: RentListResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.data.unwrap().total, 3728);
}
#[test]
fn test_rent_list_response_total_garbage_string_fails_loudly() {
use crate::types::RentListResponse;
let json = r#"{"status":1,"data":{"total":"abc","firstRow":0,"items":[]}}"#;
let result: Result<RentListResponse, _> = serde_json::from_str(json);
assert!(result.is_err(), "garbage total should fail to deserialize");
}
#[test]
fn test_rent_list_response_error_status() {
use crate::types::RentListResponse;
let json = r#"{"status":0,"msg":"參數錯誤"}"#;
let resp: RentListResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 0);
assert!(resp.data.is_none());
}
#[test]
fn test_hot_response_deserialize() {
let json = r#"{"status":1,"msg":"請求成功","data":[{"id":"123","name":"Test"},{"id":"456","name":"Other"}]}"#;
let resp: HotResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 1);
let data = resp.data.unwrap();
assert_eq!(data.len(), 2);
assert_eq!(data[0].id, "123");
assert_eq!(data[0].name, "Test");
}
#[test]
fn test_hot_response_empty_data() {
let json = r#"{"status":1,"msg":"請求成功","data":[]}"#;
let resp: HotResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.data.unwrap().len(), 0);
}
#[test]
fn test_hot_response_error_status() {
let json = r#"{"status":0,"msg":"error"}"#;
let resp: HotResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 0);
assert!(resp.data.is_none());
}
#[test]
fn test_detail_response_deserialize() {
let json = r#"{
"status": 1,
"data": {
"community": {
"id": 7329,
"name": "台北晶麒",
"region": "台北市",
"section": "萬華區",
"address": "台北市萬華區康定路103號",
"age": "10年",
"floor": "26層",
"house_holds": "687戶",
"lat": "25.0387262",
"lng": "121.5013407",
"build_purpose": "住宅",
"base_area": "1124.00",
"const_company": "興富發建設股份有限公司",
"search_count": "248,275"
},
"price": { "items": [] },
"sale": { "search_type": 1, "total": 0, "rooms": [] }
}
}"#;
let resp: DetailResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 1);
let data = resp.data.unwrap();
let community = data.community.unwrap();
assert_eq!(community.id, 7329);
assert_eq!(community.name, "台北晶麒");
assert_eq!(community.region.as_deref(), Some("台北市"));
}
#[test]
fn test_price_record_deserialize() {
let json = r#"{
"id": 7238140,
"date": "115-01-20",
"address": "康定路103號 | 18樓之16",
"layout": "1房1廳",
"build_area": "20.00坪",
"total_price": "1,490萬元",
"unit_price": { "price": "74.5", "unit": "萬/坪" },
"shift_floor": "18樓",
"total_floor": "26樓",
"build_purpose_str": "住宅"
}"#;
let record: PriceRecord = serde_json::from_str(json).unwrap();
assert_eq!(record.id, 7238140);
assert_eq!(record.date, "115-01-20");
assert_eq!(record.unit_price.price, "74.5");
}
#[test]
fn test_sale_listing_deserialize() {
let json = r#"{
"houseid": 20003249,
"title": "台北晶麒景觀精緻宅",
"price_v": { "price": "1,925", "unit": "萬" },
"price_unit": "95.0萬/坪",
"room": "1房1廳",
"address": "萬華區-台北晶麒",
"area_v": { "area": "27.83", "unit": "坪" },
"floor": "19樓",
"floor_en": "19F/26F",
"photo_src": "https://img1.591.com.tw/test.jpg",
"label": ["含車位", "有陽台"]
}"#;
let listing: SaleListing = serde_json::from_str(json).unwrap();
assert_eq!(listing.houseid, 20003249);
assert_eq!(listing.price_v.price, "1,925");
assert_eq!(listing.label.len(), 2);
}
#[test]
fn test_detail_response_not_found() {
let json = r#"{"status":0,"msg":"[id]參數錯誤"}"#;
let resp: DetailResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 0);
assert!(resp.data.is_none());
}
#[test]
fn test_community_serialize_skips_none() {
let detail = CommunityDetail {
id: 1,
name: "Test".to_string(),
region: Some("台北市".to_string()),
section: None,
address: None,
age: None,
floor: None,
house_holds: None,
lat: None,
lng: None,
build_purpose: None,
base_area: None,
const_company: None,
search_count: None,
};
let json = serde_json::to_string(&detail).unwrap();
assert!(json.contains("\"region\""));
assert!(!json.contains("\"section\""));
}
#[test]
fn test_newhouse_base_info_deserialize() {
use crate::types::{NewhouseBaseInfoResponse, NewhouseHousing};
let json = r#"{
"status": 1,
"msg": "成功",
"data": {
"housing": {
"hid": 138145,
"build_name": "春風大院",
"address": "台北市中山區",
"region": "台北市",
"regionid": 1,
"section": "中山區",
"sectionid": 3,
"community_id": 5958967,
"build_type_name": "預售屋",
"purpose_name": "住宅大樓",
"price": {"pending": 1, "price": "待定", "unit": ""},
"area": {"pending": 0, "area": "16~59", "area_min": "16.00", "unit": "坪"},
"layout": {"pending": 0, "layout": "2/3/4", "unit": "房"},
"households": "1幢,1棟,118戶住家",
"manage_cost": {"pending": 0, "price": "150", "unit": "元/坪/月"},
"cover": "https://img.591.com.tw/x.jpg",
"shop_name": "南京復興生活圈",
"tag": ["近捷運", "低公設", "景觀宅"],
"open_sell_time": 202510,
"deal_time": {"type": "finished", "date": "2030年下半年", "deal": 0},
"build_company": "待定",
"sell_company": "巨將創見廣告",
"structural_engine": "SRC",
"floor": "地上18層",
"decorate": "毛胚屋",
"park_ratio": "1:1.03",
"license": "114建字第0132號",
"use_license": "暫無",
"browsenum": 655773,
"fav_num": 318
}
}
}"#;
let resp: NewhouseBaseInfoResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 1);
let h: NewhouseHousing = resp.data.unwrap().housing.unwrap();
assert_eq!(h.hid, 138145);
assert_eq!(h.build_name, "春風大院");
assert_eq!(h.price.pending, 1);
assert_eq!(h.price.price, "待定");
assert_eq!(h.area.area, "16~59");
assert_eq!(h.area.area_min, "16.00");
assert_eq!(h.layout.layout, "2/3/4");
assert_eq!(h.tag, vec!["近捷運", "低公設", "景觀宅"]);
assert_eq!(h.deal_time.kind, "finished");
}
#[test]
fn test_newhouse_base_info_tolerates_null_numerics() {
use crate::types::NewhouseBaseInfoResponse;
let json = r#"{
"status": 1,
"data": {
"housing": {
"hid": 1, "build_name": "x", "address": "x",
"regionid": 1, "sectionid": 1,
"community_id": null,
"build_type_name": "預售屋", "purpose_name": "住宅",
"price": {"pending": 1, "price": "待定", "unit": ""},
"area": {"pending": 0, "area": "1", "unit": "坪"},
"layout": {"pending": 0, "layout": "1", "unit": "房"},
"households": "x",
"manage_cost": {"pending": 0, "price": "0", "unit": "x"},
"cover": "x", "shop_name": "x", "tag": [],
"open_sell_time": null,
"deal_time": {"type": "x", "date": "x", "deal": 0},
"build_company": "x", "sell_company": "x",
"structural_engine": "x", "floor": "x", "decorate": "x",
"park_ratio": "x", "license": "x", "use_license": "x",
"browsenum": 0, "fav_num": 0
}
}
}"#;
let resp: NewhouseBaseInfoResponse = serde_json::from_str(json).unwrap();
let h = resp.data.unwrap().housing.unwrap();
assert_eq!(h.community_id, 0);
assert_eq!(h.open_sell_time, 0);
}
#[test]
fn test_newhouse_module_info_deserialize() {
use crate::types::NewhouseModuleInfoResponse;
let json = r#"{
"status": 1,
"data": {
"layout": {"total": 0, "items": [], "room_group": []},
"market": {
"housing_id": 138145,
"housing_name": "春風大院",
"community_id": null,
"rooms": [{"name": "成交均價", "price": "149.1"}],
"items": [],
"total": null,
"update_date": "04/21"
},
"sales": {
"data": [{
"user_id": 1, "realname": "x", "mobile_v2": "0900",
"avatar": "https://x", "tags": []
}]
}
}
}"#;
let resp: NewhouseModuleInfoResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 1);
let data = resp.data.unwrap();
let market = data.market.unwrap();
assert_eq!(market.total, 0);
assert_eq!(market.community_id, 0);
assert_eq!(market.rooms.len(), 1);
assert_eq!(data.sales.data.len(), 1);
}
#[test]
fn test_newhouse_photos_deserialize() {
use crate::types::NewhousePhotosResponse;
let json = r#"{
"status": 1,
"data": [
{
"id": "logo", "name": "封面圖", "build_name": "x", "total": 1,
"items": [{
"id": 1, "cate": "logo", "cate_name": "封面圖",
"src_img": "https://x.jpg"
}]
},
{
"id": "circum", "name": "環境圖", "build_name": "x", "total": 2,
"items": [{
"id": 2, "cate": "circum", "cate_name": "環境圖",
"note": "捷運中山", "src_img": "https://y.jpg"
}]
}
]
}"#;
let resp: NewhousePhotosResponse = serde_json::from_str(json).unwrap();
let cats = resp.data.unwrap();
assert_eq!(cats.len(), 2);
assert_eq!(cats[0].id, "logo");
assert_eq!(cats[1].id, "circum");
assert_eq!(cats[1].items[0].note, "捷運中山");
}
#[test]
fn test_newhouse_surrounding_deserialize() {
use crate::types::NewhouseSurroundingResponse;
let json = r#"{
"status": 1,
"data": {
"facility": {
"total": null,
"traffic": [{
"name": "中山國中",
"distance": 876,
"distance_text": "876公尺",
"lat": 25.0521,
"lng": 121.5488,
"sub_type": "subway_station"
}],
"education": [],
"life": []
},
"housing": {
"hid": 1, "build_name": "x", "address": "x",
"map": {"pending": 0, "lat": "25.05", "lng": "121.54"},
"reception_map": {"pending": 0, "lat": "25.05", "lng": "121.54"}
}
}
}"#;
let resp: NewhouseSurroundingResponse = serde_json::from_str(json).unwrap();
let s = resp.data.unwrap();
assert_eq!(s.facility.total, 0);
assert_eq!(s.facility.traffic[0].sub_type, "subway_station");
assert_eq!(s.housing.map.lat, "25.05");
}
#[test]
fn test_newhouse_nearby_market_deserialize() {
use crate::types::NewhouseNearbyMarketResponse;
let json = r#"{
"status": 1,
"data": {
"community_items": [{
"community_id": 5880697, "community_name": "南京阿曼",
"deal_count": 67,
"price": {"content": "142.7", "unit": "萬/坪"},
"community_image": "https://x",
"build_type": 1, "build_type_str": "預售屋",
"build_purpose": "住宅",
"layout": {"content": "1、2", "unit": "房"},
"area": {"content": "13~25", "unit": "坪"},
"age": null,
"distance": 788
}],
"business_items": [{
"id": 101, "shop_id": 101, "name": "南京復興生活圈",
"price_unit": "165.0", "unit": "萬/坪"
}]
}
}"#;
let resp: NewhouseNearbyMarketResponse = serde_json::from_str(json).unwrap();
let m = resp.data.unwrap();
assert_eq!(m.community_items.len(), 1);
assert_eq!(m.community_items[0].age, 0);
assert_eq!(m.community_items[0].deal_count, 67);
assert_eq!(m.business_items.len(), 1);
}
#[test]
fn test_newhouse_price_list_deserialize() {
use crate::types::NewhousePriceListResponse;
let json = r#"{
"status": 1,
"data": {
"housing_id": 138145, "housing_name": "春風大院",
"community_id": null,
"rooms": [{"name": "成交均價", "price": ""}],
"has_sale_ctrl": 1,
"sale_ctrl_info": {
"update_count": 3,
"price": {
"id": 1, "address": "A棟7樓09戶", "room": "2房",
"unit_price": {"price": "163.0", "unit": "萬"}
}
},
"items": [],
"total": null,
"update_date": "04/21"
}
}"#;
let resp: NewhousePriceListResponse = serde_json::from_str(json).unwrap();
let p = resp.data.unwrap();
assert_eq!(p.community_id, 0);
assert_eq!(p.total, 0);
let s = p.sale_ctrl_info.unwrap();
assert_eq!(s.price.address, "A棟7樓09戶");
}
#[test]
fn test_coordinate_area_response_hit_shape() {
use crate::types::{CoordArea, CoordResponse};
let json = r#"{
"status":1, "msg":"",
"data":{"area":{"region_id":1,"region_name":"台北市","section_id":7,"section_name":"信義區"}}
}"#;
let resp: CoordResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 1);
let area_v = resp.data.get("area").cloned().unwrap();
let area: CoordArea = serde_json::from_value(area_v).unwrap();
assert_eq!(area.region_id, 1);
assert_eq!(area.region_name, "台北市");
assert_eq!(area.section_id, 7);
assert_eq!(area.section_name, "信義區");
}
#[test]
fn test_coordinate_area_response_miss_shape() {
use crate::types::CoordResponse;
let json = r#"{"status":0,"msg":"坐标不在台湾范围内","data":[]}"#;
let resp: CoordResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 0);
assert!(resp.msg.contains("台湾"));
assert!(resp.data.is_array());
}
#[test]
fn test_coordinate_area_response_unknown_status_deserializes() {
use crate::types::CoordResponse;
let json = r#"{"status":2,"msg":"rate limited","data":null}"#;
let resp: CoordResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 2);
assert_eq!(resp.msg, "rate limited");
assert!(resp.data.is_null());
}
#[test]
fn test_rent_detail_response_deserialize() {
use crate::types::RentDetailResponse;
let json = r#"{
"status": 1,
"msg": "",
"data": {
"title": "中山套房",
"price": "17,800",
"priceUnit": "元/月",
"deposit": "押金面議",
"headInfo": "17,800元/月",
"address": {
"data": "中山區雙城街50號",
"value": "中山區雙城街50號 中山區雙城街50號",
"lat": "25.0669894",
"lng": "121.5235794"
},
"regionId": 1,
"sectionId": 3,
"kind": 2,
"status": "open",
"info": [
{"name": "類型", "value": "獨立套房", "key": "kind"},
{"name": "使用坪數", "value": "10.2坪", "key": "area"}
],
"cost": {
"title": "費用詳情",
"active": 1,
"data": [
{"name": "押金", "value": "面議", "key": "deposit"}
]
},
"houseInfo": {"active": 1, "data": []},
"preference": {"active": 1, "data": []},
"service": {
"title": "提供設備",
"active": 1,
"facility": [
{"key": "fridge", "active": 1, "name": "冰箱"},
{"key": "tv", "active": 0, "name": "電視"}
],
"notice": [
{"key": "leaseTime", "name": "最短一年"},
{"key": "pet", "name": "不可養寵物"}
]
},
"surround": {
"title": "周邊配套", "key": "surround",
"address": "中山區雙城街50號",
"lat": "25.0669894", "lng": "121.5235794",
"data": [{"name": "交通", "key": "traffic", "children": [
{"type": "subway", "name": "中山國小站", "distance": 558, "distanceTxt": "距房屋約558公尺"}
]}]
},
"tags": [{"id": 16, "value": "新上架"}],
"publish": {
"id": 2, "name": "新發佈", "key": "new",
"postTime": "13小時前", "updateTime": "19分鐘內"
},
"remark": {
"title": "屋況介紹",
"key": "remark",
"active": 1,
"content": "屋況良好"
},
"linkInfo": {
"name": "程先生", "role": 3, "roleName": "仲介",
"mobile": "0922-168-660", "phone": "",
"imName": "程先生", "imUid": 780619, "uid": 780619,
"shopId": 4929, "isAgent": 0, "isGoldAgent": 0,
"certificateStatus": 2, "rentNum": 5, "saleNum": 0
}
}
}"#;
let resp: RentDetailResponse = serde_json::from_str(json).unwrap();
let d = resp.data.unwrap();
assert_eq!(d.title, "中山套房");
assert_eq!(d.price, "17,800");
assert_eq!(d.price_unit, "元/月");
assert_eq!(d.address.lat, "25.0669894");
assert_eq!(d.region_id, 1);
assert_eq!(d.kind, 2);
assert_eq!(d.info.len(), 2);
assert_eq!(d.info[0].key, "kind");
assert_eq!(d.cost.data[0].value, "面議");
assert_eq!(d.service.facility.len(), 2);
assert_eq!(d.service.facility[0].key, "fridge");
assert_eq!(d.service.facility[0].active, 1);
assert_eq!(d.service.notice.len(), 2);
assert_eq!(d.service.notice[0].key, "leaseTime");
assert_eq!(d.surround.data[0].children[0].kind, "subway");
assert_eq!(
d.surround.data[0].children[0].distance_txt,
"距房屋約558公尺"
);
assert_eq!(d.tags[0].id, 16);
assert_eq!(d.publish.post_time, "13小時前");
use crate::types::RentLinkInfoExt;
assert_eq!(d.link_info.link_str("roleName"), Some("仲介"));
assert_eq!(d.link_info.link_str("mobile"), Some("0922-168-660"));
assert_eq!(d.link_info.link_u64("shopId"), Some(4929));
}
#[test]
fn test_rent_link_info_polymorphism() {
use crate::types::{RentLinkInfo, RentLinkInfoExt};
let mapped: RentLinkInfo = serde_json::from_str(
r#"{"name":"程先生","role":3,"roleName":"仲介","mobile":"0922","shopId":4929}"#,
)
.unwrap();
assert_eq!(mapped.link_str("name"), Some("程先生"));
assert_eq!(mapped.link_u64("shopId"), Some(4929));
assert_eq!(mapped.link_u32("role"), Some(3));
let pairs: RentLinkInfo = serde_json::from_str(
r#"[{"key":"name","value":"程先生"},{"key":"role","value":3},{"key":"shopId","value":4929}]"#,
)
.unwrap();
assert_eq!(pairs.link_str("name"), Some("程先生"));
assert_eq!(pairs.link_u64("shopId"), Some(4929));
assert_eq!(pairs.link_u32("role"), Some(3));
}
#[test]
fn test_rent_photos_response_deserialize() {
use crate::types::RentPhotosResponse;
let json = r#"{
"status": 1,
"msg": "",
"data": {
"list": [{
"key": "picture",
"items": [{
"photoId": 476004995,
"photo": "https://img/big.jpg",
"origPhoto": "https://img/orig.jpg",
"thumbPhoto": "https://img/thumb.jpg",
"isCover": 1,
"purpose": 10,
"note": "",
"type": 3
}]
}]
}
}"#;
let resp: RentPhotosResponse = serde_json::from_str(json).unwrap();
let groups = resp.data.unwrap().list;
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, "picture");
assert_eq!(groups[0].items[0].photo_id, 476004995);
assert_eq!(groups[0].items[0].is_cover, 1);
}
#[test]
fn test_sale_detail_response_deserialize() {
use crate::types::SaleDetailResponse;
let json = r#"{
"status": 1,
"msg": null,
"data": {
"id": "S19599759",
"title": "三重透天",
"price": "1,988萬元",
"price_value": "1988",
"unitprice": "86.02萬/坪",
"area": "23.111坪",
"area_value": "23.111",
"layout": "3房2廳3衛",
"room": "3",
"hall": "2",
"toilet": "3",
"kind": "住宅",
"kind_id": "9",
"region": "新北市",
"region_id": "3",
"section": "三重區",
"section_id": "43",
"addr": "",
"lat": "25.0713174",
"lng": "121.4833166",
"age": "56年",
"houseage": "56",
"shape": "透天厝",
"fitment": "中檔裝潢",
"direction": "東南",
"lift": "0",
"parking": "無",
"mainarea": "20.69坪",
"managefee": "無",
"posttime": "1769328229",
"community": "",
"community_id": "",
"linkman": "值班人員",
"mobile": "0965-109-089",
"telephone": "02-85229096",
"email": "x@x.com",
"identity": "仲介",
"company_name": "有巢氏房屋",
"certificate_type": "Middleman"
}
}"#;
let resp: SaleDetailResponse = serde_json::from_str(json).unwrap();
let d = resp.data.unwrap();
assert_eq!(d.id, "S19599759");
assert_eq!(d.price, "1,988萬元");
assert_eq!(d.price_value, "1988");
assert_eq!(d.region_id, "3");
assert_eq!(d.layout, "3房2廳3衛");
assert_eq!(d.identity, "仲介");
assert_eq!(d.lat, "25.0713174");
assert_eq!(d.lat.parse::<f64>().unwrap(), 25.0713174);
assert_eq!(d.region_id.parse::<u32>().unwrap(), 3);
}
#[test]
fn test_sale_similar_wares_response_deserialize() {
use crate::types::SaleSimilarWaresResponse;
let json = r#"{
"status": 1,
"msg": "ok",
"data": [{
"type": "2",
"post_id": "19979734",
"title": "透天厝近三重",
"price": "1980",
"area": "20.808",
"kind": "9",
"is_vip": "0",
"is_refresh": "0",
"is_combine": "1",
"room": "6房6衛",
"section_name": "三重區",
"photo_url": "https://img1.591.com.tw/x.jpg",
"tag": "",
"similar_type": ""
}]
}"#;
let resp: SaleSimilarWaresResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.data.len(), 1);
assert_eq!(resp.data[0].post_id, "19979734");
assert_eq!(resp.data[0].kind_type, "2");
assert_eq!(resp.data[0].section_name, "三重區");
assert_eq!(resp.data[0].is_combine, "1");
}
#[test]
fn test_high_value_search_response_deserialize() {
use crate::types::HighValueSearchResponse;
let json = r#"{
"status": 1,
"msg": "",
"data": [{
"type": 2,
"post_id": 19916382,
"title": "郵政新村好3房",
"price": 3088,
"area": 27.1,
"kind": 9,
"room": 3,
"hall": 2,
"toilet": 1,
"region_name": "台北市",
"section_name": "大安區",
"street_name": "建國南路一段",
"unit_price": 114.1,
"cover": "https://img1.591.com.tw/x.jpg",
"unit": "萬",
"area_unit": "坪",
"layout": "3房2廳1衛"
}]
}"#;
let resp: HighValueSearchResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 1);
assert_eq!(resp.data.len(), 1);
let l = &resp.data[0];
assert_eq!(l.post_id, 19916382);
assert_eq!(l.title, "郵政新村好3房");
assert_eq!(l.price, 3088);
assert_eq!(l.region_name, "台北市");
}
#[test]
fn test_high_value_search_empty_data() {
use crate::types::HighValueSearchResponse;
let json = r#"{"status":1,"msg":"","data":[]}"#;
let resp: HighValueSearchResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, 1);
assert!(resp.data.is_empty());
}
#[test]
fn test_high_value_params_serialize_omits_empty_arrays_correctly() {
use crate::types::HighValueParams;
let params = HighValueParams::for_region(1);
let json = serde_json::to_string(¶ms).unwrap();
assert!(json.contains("\"region_id\":1"));
assert!(json.contains("\"kind\":9"));
assert!(json.contains("\"type\":2"));
assert!(json.contains("\"section_id\":[]"));
assert!(json.contains("\"shape\":[]"));
}
#[test]
fn test_regions_list() {
assert_eq!(REGIONS.len(), 22);
assert_eq!(REGIONS[0].id, 1);
assert_eq!(REGIONS[0].name, "台北市");
assert_eq!(REGIONS[5].id, 6);
assert_eq!(REGIONS[5].name, "高雄市");
for (i, r) in REGIONS.iter().enumerate() {
assert_eq!(r.id as usize, i + 1);
}
}
#[tokio::test]
async fn test_hot_live() {
let client = Client591::new().unwrap();
let result = client.hot(1, 5).await;
assert!(result.is_ok(), "hot() failed: {:?}", result);
let communities = result.unwrap();
assert!(!communities.is_empty());
assert!(communities.len() <= 5);
for c in &communities {
assert!(!c.id.is_empty());
assert!(!c.name.is_empty());
}
}
#[tokio::test]
async fn test_community_live() {
let client = Client591::new().unwrap();
let result = client.community(7329).await;
assert!(result.is_ok(), "community() failed: {:?}", result);
let detail = result.unwrap().unwrap();
assert_eq!(detail.id, 7329);
assert!(detail.region.is_some());
assert!(detail.address.is_some());
}
#[tokio::test]
async fn test_price_history_live() {
let client = Client591::new().unwrap();
let result = client.price_history(7329, 5).await;
assert!(result.is_ok(), "price_history() failed: {:?}", result);
let records = result.unwrap();
assert!(records.len() <= 5);
if !records.is_empty() {
assert!(!records[0].date.is_empty());
assert!(!records[0].layout.is_empty());
assert!(!records[0].total_price.is_empty());
}
}
#[tokio::test]
async fn test_sales_live() {
let client = Client591::new().unwrap();
let result = client.sales(7329, 5).await;
assert!(result.is_ok(), "sales() failed: {:?}", result);
let (total, listings) = result.unwrap();
assert!(total > 0, "expected sale listings for 台北晶麒");
assert!(listings.len() <= 5);
if !listings.is_empty() {
assert!(!listings[0].title.is_empty());
assert!(!listings[0].price_v.price.is_empty());
}
}
#[tokio::test]
async fn test_community_not_found() {
let client = Client591::new().unwrap();
let result = client.community(0).await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
}