pub mod search;
pub mod types;
pub use search::{crawl, search, CrawlOptions};
pub use types::{
AreaValue, Community, CommunityDetail, PriceRecord, PriceValue, Region, SaleListing,
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";
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 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_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_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());
}
}