tail_fin_591/types.rs
1use serde::{Deserialize, Deserializer, Serialize};
2
3/// 591 returns some "really an integer" fields as either a JSON
4/// integer or a JSON string depending on the listing. This helper
5/// accepts both forms and lands on `u32`. Used for `SaleHouseListing::
6/// photo_num` (string `"11"` or int `19` in the wild).
7fn u32_string_or_int<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
8 let v = serde_json::Value::deserialize(d)?;
9 match v {
10 serde_json::Value::String(s) => s.parse().map_err(serde::de::Error::custom),
11 serde_json::Value::Number(n) => n
12 .as_u64()
13 .and_then(|n| u32::try_from(n).ok())
14 .ok_or_else(|| serde::de::Error::custom(format!("u32 out of range: {n}"))),
15 other => Err(serde::de::Error::custom(format!(
16 "expected string or integer for u32, got {other:?}"
17 ))),
18 }
19}
20
21/// A hot community listing (from `/api/community/rentHot`).
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Community {
24 pub id: String,
25 pub name: String,
26}
27
28/// Detailed info for a community (from `/api/community/detail`).
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct CommunityDetail {
31 pub id: u64,
32 pub name: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub region: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub section: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub address: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub age: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub floor: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub house_holds: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub lat: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub lng: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub build_purpose: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub base_area: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub const_company: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub search_count: Option<String>,
57}
58
59/// A nearby community (from `/api/community/nearby`).
60///
61/// 591 returns up to ~5 communities geographically close to the queried
62/// `id`, each with sale-side stats (`min_price`, `sale_num`) and a
63/// distance string. Distinct from [`Community`] (which is just
64/// `{id, name}` from the rent-hot list) and [`CommunityDetail`] (which
65/// is the full info page for a single community).
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct NearbyCommunity {
68 pub id: u64,
69 pub name: String,
70 /// Numeric region id (1 = Taipei, etc.). See `REGIONS`.
71 pub region_id: u32,
72 /// Numeric section id (district within a region).
73 pub section_id: u32,
74 /// Display region (e.g. `"台北市"`).
75 pub region: String,
76 /// Display section (e.g. `"信義區"`).
77 pub section: String,
78 /// Average sale price per ping (e.g. `{ price: "150.66", unit: "萬/坪" }`).
79 pub price_unit: PriceValue,
80 pub address: String,
81 pub full_address: String,
82 /// Age string, e.g. `"5年屋齡"`. Empty for buildings 591 hasn't
83 /// dated.
84 pub age: String,
85 /// Total household count, e.g. `"282戶"`. May be empty.
86 pub house_holds: String,
87 /// `"住宅"` / `"華廈"` / `"商用"` etc.
88 pub build_purpose: String,
89 /// Distance from the queried community, e.g. `"距離126公尺"`.
90 pub distance: String,
91 /// Number of for-sale listings currently on this community.
92 pub sale_num: u32,
93 /// Minimum sale price among `sale_num` listings, in 萬元 (10,000 NTD).
94 /// Typically `0` when `sale_num == 0` (the live test asserts the
95 /// forward direction `sale_num > 0 ⇒ min_price > 0`; the reverse
96 /// is documented but not empirically verified across all cases).
97 pub min_price: u64,
98}
99
100/// A single transaction price record (from community detail `price.items`).
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct PriceRecord {
103 pub id: u64,
104 /// ROC date string, e.g. "115-01-20"
105 pub date: String,
106 pub address: String,
107 pub layout: String,
108 pub build_area: String,
109 pub total_price: String,
110 pub unit_price: PriceValue,
111 pub shift_floor: String,
112 pub total_floor: String,
113 pub build_purpose_str: String,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct PriceValue {
118 pub price: String,
119 pub unit: String,
120}
121
122/// A sale listing near a community (from community detail `sale.rooms[].items[]`).
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct SaleListing {
125 pub houseid: u64,
126 pub title: String,
127 pub price_v: PriceValue,
128 pub price_unit: String,
129 pub room: String,
130 pub address: String,
131 pub area_v: AreaValue,
132 pub floor: String,
133 pub floor_en: String,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub photo_src: Option<String>,
136 pub label: Vec<String>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct AreaValue {
141 pub area: String,
142 pub unit: String,
143}
144
145/// A rental listing from `bff-house.591.com.tw/v3/web/rent/list` (pure HTTP).
146///
147/// Wire fields are mapped via `From<RentListItem>`. `price` is the
148/// formatted display string (`"17,500"`, with comma); `area` carries
149/// the unit suffix (`"9坪"`); `post_time` is a Chinese relative-time
150/// string (`"7小時內更新"`), not an ISO date.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct SearchListing {
153 pub post_id: u64,
154 pub title: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 pub price: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub price_unit: Option<String>,
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub address: Option<String>,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub area: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub kind_name: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub room: Option<String>,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub floor: Option<String>,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub photo_list: Option<Vec<String>>,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub tags: Option<Vec<String>>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub post_time: Option<String>,
175}
176
177/// Parameters for the rental search.
178#[derive(Debug, Clone, Default)]
179pub struct SearchParams {
180 /// Region ID (1 = Taipei City). See `REGIONS` for full list.
181 pub region_id: u32,
182 /// House kind: 0=all, 1=整層住家, 2=獨立套房, 3=分租套房, 8=雅房, 24=車位
183 pub kind: Option<u32>,
184 /// Maximum monthly rent (NTD)
185 pub price_max: Option<u32>,
186 /// Minimum monthly rent (NTD)
187 pub price_min: Option<u32>,
188 /// Sort field: "posttime" (default) or "price"
189 pub order: Option<String>,
190 /// Max results to return (per page)
191 pub limit: usize,
192 /// Pagination offset (0, 30, 60, …). Used by `crawl` to walk through pages.
193 pub first_row: usize,
194}
195
196/// Options controlling the pagination behaviour of [`crate::Client591::rent_crawl`].
197#[derive(Debug, Clone)]
198pub struct CrawlOptions {
199 /// Maximum pages to fetch. 0 = no limit.
200 pub max_pages: usize,
201 /// Milliseconds to wait between pages.
202 pub delay_ms: u64,
203 /// Retry attempts per page on failure (with 2s fixed backoff between retries).
204 pub retries: u32,
205 /// Starting page index (for resume). 0 = start from beginning.
206 pub start_page: usize,
207}
208
209impl Default for CrawlOptions {
210 fn default() -> Self {
211 Self {
212 max_pages: 0,
213 delay_ms: 1000,
214 retries: 3,
215 start_page: 0,
216 }
217 }
218}
219
220/// A region with its ID and name.
221#[derive(Debug, Clone, Serialize)]
222pub struct Region {
223 pub id: u32,
224 pub name: &'static str,
225}
226
227// ---------------------------------------------------------------------------
228// BFF endpoints — sale_list / newhouse_list / community_rank
229// ---------------------------------------------------------------------------
230
231/// A single sale listing from `bff-house.591/v1/web/sale/list`.
232///
233/// 591's BFF response carries 60+ fields per listing; this type
234/// surfaces the user-facing subset (identity / location / price /
235/// area / display strings) and skips ad/internal flags.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct SaleHouseListing {
238 pub houseid: u64,
239 pub title: String,
240 pub address: String,
241 /// Area in 坪 (Taiwanese unit, ~3.3 m²).
242 pub area: f64,
243 /// Total sale price in 萬元 (10,000 NTD).
244 pub price: u64,
245 /// Pre-formatted price string (e.g. `"1,798"`).
246 pub showprice: String,
247 /// Per-坪 price (e.g. `"119.71萬/坪"`).
248 pub unit_price: String,
249 /// Floor / total floors (e.g. `"5F/12F"`).
250 pub floor: String,
251 /// Room / 廳 / 衛 layout (e.g. `"2房1廳1衛"`).
252 pub room: String,
253 /// `"住宅"` / `"華廈"` / `"商用"` / etc.
254 pub kind_name: String,
255 /// `"電梯大樓"` / `"公寓"` / etc.
256 pub shape_name: String,
257 pub region_id: u32,
258 pub region_name: String,
259 pub section_id: u32,
260 pub section_name: String,
261 /// Numeric age in years.
262 pub houseage: u32,
263 /// Human-readable age (`"1年"`).
264 pub showhouseage: String,
265 /// Unix seconds.
266 pub posttime: i64,
267 /// View count.
268 pub browsenum: u64,
269 pub photo_url: String,
270 /// Number of photos for the listing. JSON key `"photoNum"`. 591
271 /// returns this as **either** a JSON string (`"11"`) or a raw
272 /// integer (`19`); we accept both forms via `u32_string_or_int`.
273 #[serde(rename = "photoNum", deserialize_with = "u32_string_or_int")]
274 pub photo_num: u32,
275 /// Owner's display name (`"屋主金小姐"`).
276 pub nick_name: String,
277 /// Listing tags (often empty).
278 #[serde(default)]
279 pub tag: Vec<String>,
280 /// Community/building name. Falls back to `"依現場名稱"` placeholder
281 /// for owner-direct listings without a registered community.
282 pub community_name: String,
283 /// 591 community detail link (empty when no registered community).
284 #[serde(default)]
285 pub community_link: String,
286 /// Number of for-sale listings on this community. 591 omits this
287 /// field entirely for owner-direct listings without a registered
288 /// community — `#[serde(default)]` lands those at 0.
289 #[serde(default)]
290 pub community_sale_num: u32,
291 /// "Last refreshed" indicator. 591 returns this as **either**:
292 /// - a Chinese relative-time string (`"2小時前"`, `"剛剛"`), or
293 /// - a Unix timestamp integer (`1777397463`)
294 ///
295 /// depending on the listing's age class. Exposed as raw
296 /// `serde_json::Value` so callers can match on the discriminant
297 /// rather than fight a custom deserializer. For "when was this
298 /// posted, full stop" use the stable `posttime` field.
299 pub refreshtime: serde_json::Value,
300}
301
302/// One page of sale listings from `/v1/web/sale/list`.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct SaleHousePage {
305 /// Total matching listings across all pages.
306 pub total: u32,
307 /// 0-indexed first row of this page.
308 pub first_row: usize,
309 pub houses: Vec<SaleHouseListing>,
310}
311
312/// A single new-construction project from `bff-newhouse.591/v1/list-search`.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct NewhouseProject {
315 /// 建案 ID — 591's "house" id for newhouse projects.
316 pub hid: u64,
317 /// Linked community ID (the post-handover community in the
318 /// rent / sale catalog), if 591 has a record.
319 pub community_id: u64,
320 /// 建案名稱.
321 pub build_name: String,
322 /// Full address (`"台北市松山區八德路三段201號"`).
323 pub address: String,
324 /// Area range string (`"20~26坪"`).
325 pub area: String,
326 /// Room layout summary (`"二房(20、26坪)"`).
327 pub room: String,
328 /// Price string — usually a single number (e.g. `"138"`); 591
329 /// formats this without a thousands separator for newhouse.
330 pub price: String,
331 /// Unit suffix — typically `"萬/坪"`.
332 pub price_unit: String,
333 /// `"住商用"` / `"住宅"` / etc.
334 pub purpose_str: String,
335 pub region: String,
336 #[serde(rename = "regionid")]
337 pub region_id: u32,
338 pub section: String,
339 #[serde(rename = "sectionid")]
340 pub section_id: u32,
341 /// Nearest landmark / 商圈 (`"台北小巨蛋生活圈"`).
342 pub shop_name: String,
343 /// Cover photo URL.
344 pub cover: String,
345 /// Sales-office phone.
346 pub phone: String,
347 pub phone_ext: String,
348 /// Marketing tags (`["近捷運", "明星學區", "近公園", "低首付"]`).
349 #[serde(default)]
350 pub tag: Vec<String>,
351 /// `"2026-04-28"`.
352 pub updatetime: String,
353 /// Whether the project has a video tour.
354 pub is_video: u8,
355 /// Visibility tier text (`"區域VIP"` / etc); empty for non-paid.
356 pub group_type_txt: String,
357}
358
359/// One page of newhouse projects from `/v1/list-search`.
360///
361/// Constructed from a wire response that mixes empty placeholder slots
362/// (single-key objects from 591's ad-injection layer) into the items
363/// array. The adapter filters those out — `items` here only contains
364/// successfully-parsed `NewhouseProject` entries.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct NewhousePage {
367 /// Total projects matching the filter.
368 pub total: u32,
369 /// Currently-online projects.
370 pub online_total: u32,
371 /// 1-indexed page number.
372 pub page: u32,
373 pub per_page: u32,
374 pub total_page: u32,
375 pub items: Vec<NewhouseProject>,
376}
377
378/// 591's `photo_src` shape — appears across rank / various BFF
379/// endpoints as an object with the actual URL inside.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct PhotoSrc {
382 /// Image URL.
383 pub src: String,
384 /// 591-internal type discriminant.
385 #[serde(default, rename = "type")]
386 pub kind: u32,
387}
388
389/// One entry in a community-rank slot. Used for both `price_data`
390/// (price-ranked) and `sale_data` (sale-activity-ranked) buckets.
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct CommunityRankItem {
393 pub cid: u64,
394 pub name: String,
395 pub address: String,
396 /// Cover photo (object form: `{src, type}`).
397 pub photo_src: PhotoSrc,
398 /// Average per-坪 price.
399 pub price: PriceValue,
400 /// Numeric metric used for the ranking — depends on slot:
401 /// in `price_data` this is the priced-by metric (some kind of
402 /// listings-volume figure); in `sale_data` it tracks paid-rank
403 /// activity. Value as observed: `1059` for the top entry.
404 pub price_num: u64,
405 /// Number of for-sale listings in this community.
406 pub sale_num: u32,
407 /// Nearest landmark / station / 商圈.
408 pub shop_name: String,
409}
410
411/// Community ranks for a region from `/v1/community/community-rank`.
412///
413/// 591 returns two parallel rankings of the same shape: `price_data`
414/// (top 10 by price-related metric) and `sale_data` (top 10 by sale
415/// activity). Both share the same `time` snapshot date.
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct CommunityRanks {
418 pub price_data: Vec<CommunityRankItem>,
419 pub sale_data: Vec<CommunityRankItem>,
420 /// Snapshot date (`"2026-04-29"`).
421 pub time: String,
422}
423
424// ---------------------------------------------------------------------------
425// /v3/web/rent/list — pure-HTTP rent search (used by Client591::rent_search)
426// ---------------------------------------------------------------------------
427
428/// Top-level wrapper for `bff-house.591.com.tw/v3/web/rent/list`.
429///
430/// `status` is a JSON integer (`1` for success), `total` is a JSON
431/// string (`"3728"`) — verified live 2026-04-30. The wire `firstRow`
432/// field is intentionally not modeled because 591 echoes 0 even on
433/// later pages; the caller tracks its own offset.
434#[derive(Debug, Clone, Deserialize)]
435pub(crate) struct RentListResponse {
436 pub status: i32,
437 pub data: Option<RentListData>,
438}
439
440#[derive(Debug, Clone, Deserialize)]
441pub(crate) struct RentListData {
442 /// Total matching listings. 591 currently sends this as a JSON
443 /// string (`"3728"`), but `u32_string_or_int_default` accepts the
444 /// int form too — forward-compat for a server-side type flip and
445 /// loud-fail on garbage strings (rather than the old silent zero).
446 #[serde(default, deserialize_with = "u32_string_or_int_default")]
447 pub total: u32,
448 pub items: Vec<RentListItem>,
449}
450
451/// A single rental listing as returned by `/v3/web/rent/list`.
452///
453/// Wire shape — fields are mapped to the public [`SearchListing`]
454/// via `From<RentListItem>`. Most secondary fields (browse_count,
455/// surrounding, fitment_name, etc.) are dropped intentionally —
456/// they're not part of `SearchListing`'s public contract.
457#[derive(Debug, Clone, Deserialize)]
458pub(crate) struct RentListItem {
459 pub id: u64,
460 pub title: String,
461 /// Display price (e.g. `"17,500"` — note the comma).
462 #[serde(default)]
463 pub price: Option<String>,
464 /// Price unit (`"元/月"`).
465 #[serde(default)]
466 pub price_unit: Option<String>,
467 #[serde(default)]
468 pub address: Option<String>,
469 /// Area display string (`"9坪"`). Numeric `area` is present
470 /// too but we keep the existing `SearchListing.area: String`
471 /// contract for compatibility.
472 #[serde(default)]
473 pub area_name: Option<String>,
474 #[serde(default)]
475 pub kind_name: Option<String>,
476 /// Layout string. Often empty for 套房 (`""`); populated
477 /// for whole-floor rentals (`"2房1廳"`).
478 #[serde(default, rename = "layoutStr")]
479 pub layout_str: Option<String>,
480 #[serde(default)]
481 pub floor_name: Option<String>,
482 #[serde(default, rename = "photoList")]
483 pub photo_list: Option<Vec<String>>,
484 #[serde(default)]
485 pub tags: Option<Vec<String>>,
486 /// Relative-time string ("7小時內更新", "剛剛更新"). Used
487 /// as the closest analog to `SearchListing.post_time`.
488 #[serde(default)]
489 pub refresh_time: Option<String>,
490}
491
492impl From<RentListItem> for SearchListing {
493 fn from(v: RentListItem) -> Self {
494 // Empty strings / empty Vecs collapse to None to match the
495 // semantics the existing parse_listing helper used and the
496 // `#[serde(skip_serializing_if = "Option::is_none")]` output
497 // contract on SearchListing.
498 let room = v.layout_str.filter(|s| !s.is_empty());
499 let photo_list = v.photo_list.filter(|v| !v.is_empty());
500 let tags = v.tags.filter(|v| !v.is_empty());
501 SearchListing {
502 post_id: v.id,
503 title: v.title,
504 price: v.price,
505 price_unit: v.price_unit,
506 address: v.address,
507 area: v.area_name,
508 kind_name: v.kind_name,
509 room,
510 floor: v.floor_name,
511 photo_list,
512 tags,
513 post_time: v.refresh_time,
514 }
515 }
516}
517
518// ---------------------------------------------------------------------------
519// Newhouse detail family (bff-newhouse.591.com.tw/v1/detail/*, /v1/price/list)
520// ---------------------------------------------------------------------------
521
522/// A pending-aware monetary or numeric field. 591 wraps "value or
523/// TBD" data in this shape: when `pending: 1`, `price` carries the
524/// placeholder string `"待定"` (TBD) and `unit` is empty; when
525/// `pending: 0`, both fields carry real values.
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct PendingPrice {
528 pub pending: u8,
529 pub price: String,
530 pub unit: String,
531}
532
533/// Like [`PendingPrice`] but for area ranges. The wire shape adds
534/// `area_min` for the lower bound when `pending: 0`.
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct PendingArea {
537 pub pending: u8,
538 /// Range string (e.g. `"16~59"`).
539 pub area: String,
540 /// Lower-bound value (e.g. `"16.00"`). Empty/absent when pending.
541 #[serde(default)]
542 pub area_min: String,
543 pub unit: String,
544}
545
546/// Like [`PendingPrice`] but the value field is named `layout`
547/// (e.g. `"2/3/4"` for a project offering 2-, 3-, and 4-room units).
548#[derive(Debug, Clone, Serialize, Deserialize)]
549pub struct PendingRoom {
550 pub pending: u8,
551 pub layout: String,
552 pub unit: String,
553}
554
555/// Newhouse handover-date metadata.
556#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct DealTime {
558 /// `"finished"` (project complete) or `"unfinished"` (still selling).
559 #[serde(rename = "type")]
560 pub kind: String,
561 /// Display string (e.g. `"2030年下半年"`).
562 pub date: String,
563 /// Sales-progress flag.
564 pub deal: u8,
565}
566
567/// Newhouse project detail (curated subset of `/v1/detail/base-info`'s
568/// `data.housing` block — the wire shape carries 80+ fields, this
569/// surfaces the ~25 most useful for project-shopping workflows).
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct NewhouseHousing {
572 /// 591 housing/project ID. Same value as the URL `id`/`hid` param.
573 pub hid: u64,
574 pub build_name: String,
575 pub address: String,
576 /// Display region (e.g. `"台北市"`).
577 #[serde(default)]
578 pub region: Option<String>,
579 pub regionid: u32,
580 /// Display section (e.g. `"中山區"`).
581 #[serde(default)]
582 pub section: Option<String>,
583 pub sectionid: u32,
584 /// Linked community ID once the project is delivered (0 until then).
585 /// 591 sometimes serializes this as `null` for unlinked pre-sales —
586 /// the deserializer collapses null/missing/string forms to 0.
587 #[serde(default, deserialize_with = "u64_string_or_int_default")]
588 pub community_id: u64,
589 /// `"預售屋"` (pre-sale), `"新成屋"` (new), `"中古屋"` (resale).
590 pub build_type_name: String,
591 /// `"住宅大樓"`, `"住商混合"`, etc.
592 pub purpose_name: String,
593 pub price: PendingPrice,
594 pub area: PendingArea,
595 /// Project's offered room layouts (e.g. `"2/3/4"`).
596 pub layout: PendingRoom,
597 /// Free-form description (`"1幢,1棟,118戶住家"`).
598 pub households: String,
599 /// Per-坪/月 management fee.
600 pub manage_cost: PendingPrice,
601 /// Cover image URL.
602 pub cover: String,
603 /// Nearest landmark / 商圈 (`"南京復興生活圈"`).
604 pub shop_name: String,
605 /// Marketing tags (`["近捷運", "低公設", "景觀宅"]`).
606 #[serde(default)]
607 pub tag: Vec<String>,
608 /// `YYYYMM` integer (e.g. `202510`). 0 for unset / null on the wire.
609 #[serde(default, deserialize_with = "u32_string_or_int_default")]
610 pub open_sell_time: u32,
611 pub deal_time: DealTime,
612 /// Construction company.
613 pub build_company: String,
614 /// Sales agency.
615 pub sell_company: String,
616 /// `"SRC鋼骨鋼筋混凝土結構"` etc.
617 pub structural_engine: String,
618 /// `"地上18層,地下4層"`.
619 pub floor: String,
620 /// `"毛胚屋、標準配備"` etc.
621 pub decorate: String,
622 /// Parking ratio (`"1:1.03"`).
623 pub park_ratio: String,
624 /// Pre-construction license (建照).
625 pub license: String,
626 /// Use license / occupancy permit (使照).
627 pub use_license: String,
628 /// All-time browse count.
629 pub browsenum: u64,
630 /// Favourite count.
631 pub fav_num: u64,
632}
633
634/// One market-data row — either a per-room average (`name: "2房均價"`)
635/// or a project-wide aggregate (`name: "成交均價"`).
636#[derive(Debug, Clone, Serialize, Deserialize)]
637pub struct NewhouseMarketRoom {
638 /// Display label (`"成交均價"`, `"2房均價"`, …).
639 pub name: String,
640 /// Average per-坪 price as a string (e.g. `"149.1"`, in 萬/坪).
641 /// Empty when the project has no transactions yet.
642 pub price: String,
643}
644
645/// One actual-transaction record from the project's market history.
646/// 591 surfaces the most recent N transactions on the project page;
647/// older rows can be paginated via the price-list endpoint.
648#[derive(Debug, Clone, Serialize, Deserialize)]
649pub struct NewhouseMarketItem {
650 pub id: u64,
651 /// ISO date (`"2026-03-10"`).
652 pub trans_date: String,
653 /// ROC year-month (`"115-03"`).
654 pub month: String,
655 /// Layout (`"2房2廳"`, `"3房2廳"`).
656 pub layout_v2: String,
657 pub build_area_v: AreaValue,
658 pub building_area: AreaValue,
659 pub unit_price: PriceValue,
660 /// Total transaction price (e.g. `"3,968"` in 萬元 — comma-separated).
661 pub total_price_v: String,
662 pub shift_floor: String,
663}
664
665/// Project market block — average prices + recent transactions.
666#[derive(Debug, Clone, Serialize, Deserialize)]
667pub struct NewhouseMarket {
668 pub housing_id: u64,
669 pub housing_name: String,
670 /// Linked community ID once delivered (0 / null until then).
671 #[serde(default, deserialize_with = "u64_string_or_int_default")]
672 pub community_id: u64,
673 /// Per-room average prices (and project-wide aggregate).
674 #[serde(default)]
675 pub rooms: Vec<NewhouseMarketRoom>,
676 /// Recent transactions (most-recent first).
677 #[serde(default)]
678 pub items: Vec<NewhouseMarketItem>,
679 /// Total number of transactions on record (across all pages).
680 #[serde(default, deserialize_with = "u64_string_or_int_default")]
681 pub total: u64,
682 /// MM/DD of the last 591-side data refresh (`"04/21"`).
683 #[serde(default)]
684 pub update_date: String,
685}
686
687/// Floor-plan block. Empty for projects that haven't published plans.
688#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct NewhouseLayoutBlock {
690 /// Total number of floor plans available.
691 #[serde(default, deserialize_with = "u64_string_or_int_default")]
692 pub total: u64,
693 /// Raw items (kept as `Value` because the wire shape is sparse on
694 /// our pinned test project — typing it would be guesswork).
695 #[serde(default)]
696 pub items: Vec<serde_json::Value>,
697 #[serde(default)]
698 pub room_group: Vec<serde_json::Value>,
699}
700
701/// Single listed sales agent for a newhouse project.
702#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct NewhouseSalesAgent {
704 pub user_id: u64,
705 /// Display name (often the project name itself, sometimes an
706 /// individual rep).
707 pub realname: String,
708 pub mobile_v2: String,
709 #[serde(default)]
710 pub email: String,
711 #[serde(default)]
712 pub introduction: String,
713 pub avatar: String,
714 /// Marketing-side tags (`["17人諮詢"]`, etc.).
715 #[serde(default)]
716 pub tags: Vec<String>,
717}
718
719/// Aggregate of `/v1/detail/module-info` data — the curated subset
720/// across `layout` / `market` / `sales`. `news` and `report` are
721/// dropped (mostly empty across the projects we sampled).
722#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct NewhouseModules {
724 pub layout: NewhouseLayoutBlock,
725 pub market: Option<NewhouseMarket>,
726 /// Listed sales agents.
727 pub sales_agents: Vec<NewhouseSalesAgent>,
728}
729
730#[derive(Debug, Clone, Deserialize)]
731pub(crate) struct NewhouseModuleInfoResponse {
732 pub status: i32,
733 pub msg: Option<String>,
734 pub data: Option<NewhouseModuleInfoData>,
735}
736
737#[derive(Debug, Clone, Deserialize)]
738pub(crate) struct NewhouseModuleInfoData {
739 #[serde(default = "default_layout")]
740 pub layout: NewhouseLayoutBlock,
741 pub market: Option<NewhouseMarket>,
742 #[serde(default)]
743 pub sales: NewhouseSalesData,
744}
745
746fn default_layout() -> NewhouseLayoutBlock {
747 NewhouseLayoutBlock {
748 total: 0,
749 items: vec![],
750 room_group: vec![],
751 }
752}
753
754#[derive(Debug, Clone, Default, Deserialize)]
755pub(crate) struct NewhouseSalesData {
756 #[serde(default)]
757 pub data: Vec<NewhouseSalesAgent>,
758}
759
760/// Aggregated result of `Client591::newhouse_detail` — the merged
761/// output of 6 parallel sub-endpoint calls. Sub-calls that fail
762/// independently land their error message in the corresponding
763/// `*_error` field rather than failing the whole bundle; callers
764/// inspecting the bundle should expect partial coverage on
765/// projects with sparse data.
766#[derive(Debug, Clone, Serialize)]
767pub struct NewhouseDetail {
768 pub housing: Option<NewhouseHousing>,
769 #[serde(skip_serializing_if = "Option::is_none")]
770 pub housing_error: Option<String>,
771
772 pub modules: Option<NewhouseModules>,
773 #[serde(skip_serializing_if = "Option::is_none")]
774 pub modules_error: Option<String>,
775
776 /// Categorized photo gallery (empty Vec if unavailable, distinct
777 /// from `photos_error: Some(_)` which means the call failed).
778 pub photos: Vec<NewhousePhotoCategory>,
779 #[serde(skip_serializing_if = "Option::is_none")]
780 pub photos_error: Option<String>,
781
782 pub surrounding: Option<NewhouseSurrounding>,
783 #[serde(skip_serializing_if = "Option::is_none")]
784 pub surrounding_error: Option<String>,
785
786 pub nearby_market: Option<NewhouseNearbyMarket>,
787 #[serde(skip_serializing_if = "Option::is_none")]
788 pub nearby_market_error: Option<String>,
789
790 pub price_list: Option<NewhousePriceList>,
791 #[serde(skip_serializing_if = "Option::is_none")]
792 pub price_list_error: Option<String>,
793}
794
795/// Most-recent sale-control entry (the latest unit 591 has data on for a project).
796#[derive(Debug, Clone, Serialize, Deserialize)]
797pub struct NewhouseSaleCtrlPrice {
798 pub id: u64,
799 /// Unit address (`"A棟7樓09戶"`).
800 pub address: String,
801 /// Layout (`"2房"`, `"3房"`, …).
802 pub room: String,
803 pub unit_price: PriceValue,
804}
805
806#[derive(Debug, Clone, Serialize, Deserialize)]
807pub struct NewhouseSaleCtrlInfo {
808 /// How many price updates have been recorded.
809 pub update_count: u64,
810 pub price: NewhouseSaleCtrlPrice,
811}
812
813/// `/v1/price/list` response — project price catalogue.
814#[derive(Debug, Clone, Serialize, Deserialize)]
815pub struct NewhousePriceList {
816 pub housing_id: u64,
817 pub housing_name: String,
818 /// Linked community ID (0 / null if pre-handover).
819 #[serde(default, deserialize_with = "u64_string_or_int_default")]
820 pub community_id: u64,
821 /// Per-room average prices (e.g. 成交均價, 2房均價, …). Same
822 /// shape as [`NewhouseMarketRoom`].
823 #[serde(default)]
824 pub rooms: Vec<NewhouseMarketRoom>,
825 /// Whether 591 has sale-control data for this project.
826 #[serde(default)]
827 pub has_sale_ctrl: u8,
828 /// Most-recent recorded unit (when `has_sale_ctrl: 1`).
829 pub sale_ctrl_info: Option<NewhouseSaleCtrlInfo>,
830 /// Per-unit transaction items (often empty for active pre-sale
831 /// projects — module-info's `market.items` is usually richer).
832 #[serde(default)]
833 pub items: Vec<NewhouseMarketItem>,
834 /// Total number of items across all pages.
835 #[serde(default, deserialize_with = "u64_string_or_int_default")]
836 pub total: u64,
837 /// MM/DD of last 591-side refresh (`"04/21"`).
838 #[serde(default)]
839 pub update_date: String,
840}
841
842#[derive(Debug, Clone, Deserialize)]
843pub(crate) struct NewhousePriceListResponse {
844 pub status: i32,
845 pub msg: Option<String>,
846 pub data: Option<NewhousePriceList>,
847}
848
849/// `{content, unit}` pair — used by nearby-market for compact value+unit display.
850#[derive(Debug, Clone, Serialize, Deserialize)]
851pub struct ContentUnit {
852 pub content: String,
853 pub unit: String,
854}
855
856/// One nearby community resale comp from `/v1/detail/nearby-market`.
857#[derive(Debug, Clone, Serialize, Deserialize)]
858pub struct NewhouseNearbyComm {
859 pub community_id: u64,
860 pub community_name: String,
861 /// Total transactions on record.
862 pub deal_count: u64,
863 /// Average per-坪 price (e.g. `{content: "142.7", unit: "萬/坪"}`).
864 pub price: ContentUnit,
865 pub community_image: String,
866 /// Internal type code (0/1).
867 #[serde(default)]
868 pub build_type: u8,
869 /// Display string (`"預售屋"`, `"3年社區"`, `"5年社區"`, …).
870 pub build_type_str: String,
871 pub build_purpose: String,
872 /// Layout summary (`{content: "1、2", unit: "房"}` = 1- and 2-bed units).
873 pub layout: ContentUnit,
874 /// Area range (`{content: "13~25", unit: "坪"}`).
875 pub area: ContentUnit,
876 /// Age in years (0 / null for pre-sale).
877 #[serde(default, deserialize_with = "u32_string_or_int_default")]
878 pub age: u32,
879 /// Distance to the queried project in metres.
880 pub distance: u64,
881}
882
883/// One nearby business / 商圈 from `/v1/detail/nearby-market`.
884#[derive(Debug, Clone, Serialize, Deserialize)]
885pub struct NewhouseNearbyBusiness {
886 pub id: u64,
887 pub shop_id: u64,
888 pub name: String,
889 /// Average per-坪 price for the 商圈 (e.g. `"165.0"`).
890 pub price_unit: String,
891 pub unit: String,
892}
893
894/// Aggregate of `/v1/detail/nearby-market` data.
895#[derive(Debug, Clone, Serialize, Deserialize)]
896pub struct NewhouseNearbyMarket {
897 /// Nearby communities (resale comps).
898 #[serde(default)]
899 pub community_items: Vec<NewhouseNearbyComm>,
900 /// Nearby business districts (`商圈`).
901 #[serde(default)]
902 pub business_items: Vec<NewhouseNearbyBusiness>,
903}
904
905#[derive(Debug, Clone, Deserialize)]
906pub(crate) struct NewhouseNearbyMarketResponse {
907 pub status: i32,
908 pub msg: Option<String>,
909 pub data: Option<NewhouseNearbyMarket>,
910}
911
912/// One nearby POI from `/v1/detail/surrounding`.
913#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct NewhousePoi {
915 pub name: String,
916 /// Walking distance in metres (591's own measurement).
917 pub distance: u64,
918 /// Pre-formatted distance string (`"距建案約876公尺"`).
919 pub distance_text: String,
920 pub lat: f64,
921 pub lng: f64,
922 /// Sub-type code (`"subway_station"`, `"bus_station"`,
923 /// `"grade"` (elementary), `"middle"`, `"shopping_mall"`,
924 /// `"department_store"`, …).
925 pub sub_type: String,
926}
927
928/// Categorized POIs around a newhouse project, with totals.
929#[derive(Debug, Clone, Serialize, Deserialize)]
930pub struct NewhouseSurroundingFacility {
931 /// Total POIs across all categories (591's own count).
932 #[serde(default, deserialize_with = "u64_string_or_int_default")]
933 pub total: u64,
934 /// Transit (subway, bus, train).
935 #[serde(default)]
936 pub traffic: Vec<NewhousePoi>,
937 /// Schools (elementary, middle, high, college).
938 #[serde(default)]
939 pub education: Vec<NewhousePoi>,
940 /// Daily-life amenities (shops, restaurants, parks, …).
941 #[serde(default)]
942 pub life: Vec<NewhousePoi>,
943}
944
945/// `lat`/`lng` are wire-strings, not numbers — `MapCoord` parses them.
946#[derive(Debug, Clone, Serialize, Deserialize)]
947pub struct MapCoord {
948 pub lat: String,
949 pub lng: String,
950 #[serde(default)]
951 pub pending: u8,
952}
953
954/// Project location metadata from `/v1/detail/surrounding`'s housing block.
955#[derive(Debug, Clone, Serialize, Deserialize)]
956pub struct NewhouseSurroundingHousing {
957 pub hid: u64,
958 pub build_name: String,
959 pub address: String,
960 /// Project building location.
961 pub map: MapCoord,
962 /// Sales-office address.
963 #[serde(default)]
964 pub reception_address: String,
965 /// Sales-office location.
966 pub reception_map: MapCoord,
967}
968
969/// Aggregate of `/v1/detail/surrounding` data.
970#[derive(Debug, Clone, Serialize, Deserialize)]
971pub struct NewhouseSurrounding {
972 pub facility: NewhouseSurroundingFacility,
973 pub housing: NewhouseSurroundingHousing,
974}
975
976#[derive(Debug, Clone, Deserialize)]
977pub(crate) struct NewhouseSurroundingResponse {
978 pub status: i32,
979 pub msg: Option<String>,
980 pub data: Option<NewhouseSurrounding>,
981}
982
983/// One photo within a [`NewhousePhotoCategory`].
984#[derive(Debug, Clone, Serialize, Deserialize)]
985pub struct NewhousePhoto {
986 pub id: u64,
987 /// Category code (`"logo"`, `"plan"`, `"realistic"`, `"circum"`, …).
988 pub cate: String,
989 /// Display category name (`"封面圖"`, `"平面圖"`, `"實景圖"`, `"環境圖"`, …).
990 pub cate_name: String,
991 /// Optional caption (e.g. `"捷運中山國中站"` for an environment photo).
992 #[serde(default)]
993 pub note: String,
994 /// 900px-wide watermarked variant (the "main" gallery image).
995 pub src_img: String,
996 /// 160×120 thumbnail.
997 #[serde(default)]
998 pub small_img: String,
999 /// 750px-wide variant (mobile-optimized).
1000 #[serde(default)]
1001 pub big_img: String,
1002 #[serde(default)]
1003 pub description: String,
1004}
1005
1006/// One photo category bucket (cover / floor plan / traffic / 3D / real-life / environment).
1007#[derive(Debug, Clone, Serialize, Deserialize)]
1008pub struct NewhousePhotoCategory {
1009 /// Category code (`"logo"`, `"plan"`, …).
1010 pub id: String,
1011 /// Display name (`"封面圖"`, `"平面圖"`, …).
1012 pub name: String,
1013 pub build_name: String,
1014 pub total: u64,
1015 #[serde(default)]
1016 pub items: Vec<NewhousePhoto>,
1017}
1018
1019/// `/v1/detail/photos` returns `data` as a top-level array of
1020/// categories (not an object). This wrapper deserializes the
1021/// `{status, msg, data: [...]}` envelope into a Vec of categories.
1022#[derive(Debug, Clone, Deserialize)]
1023pub(crate) struct NewhousePhotosResponse {
1024 pub status: i32,
1025 pub msg: Option<String>,
1026 pub data: Option<Vec<NewhousePhotoCategory>>,
1027}
1028
1029/// Top-level wrapper for `/v1/detail/base-info`. Other top-level
1030/// keys (`meta`, `nearby`, `dynamic`, `same_region_section`, etc.)
1031/// are dropped intentionally — call other `newhouse_*` methods for
1032/// those data dimensions.
1033#[derive(Debug, Clone, Deserialize)]
1034pub(crate) struct NewhouseBaseInfoResponse {
1035 pub status: i32,
1036 pub msg: Option<String>,
1037 pub data: Option<NewhouseBaseInfoData>,
1038}
1039
1040#[derive(Debug, Clone, Deserialize)]
1041pub(crate) struct NewhouseBaseInfoData {
1042 pub housing: Option<NewhouseHousing>,
1043}
1044
1045// ---------------------------------------------------------------------------
1046// /v1/high-value/search — premium-listing curated search
1047// ---------------------------------------------------------------------------
1048
1049/// Inclusive numeric range with optional bounds. 591's high-value
1050/// endpoint accepts an array of these for `price` and `area`
1051/// filtering — both ends may be `null` to mean "no bound on this side".
1052#[derive(Debug, Clone, Serialize, Deserialize)]
1053pub struct PriceRange {
1054 /// Lower bound in 萬 (10,000 NTD). `None` means no lower limit.
1055 #[serde(skip_serializing_if = "Option::is_none")]
1056 pub start: Option<u32>,
1057 /// Upper bound in 萬. `None` means no upper limit.
1058 #[serde(skip_serializing_if = "Option::is_none")]
1059 pub end: Option<u32>,
1060}
1061
1062/// Inclusive area range in 坪 (Taiwanese unit, ~3.3 m²). Both ends optional.
1063#[derive(Debug, Clone, Serialize, Deserialize)]
1064pub struct AreaRange {
1065 #[serde(skip_serializing_if = "Option::is_none")]
1066 pub start: Option<f64>,
1067 #[serde(skip_serializing_if = "Option::is_none")]
1068 pub end: Option<f64>,
1069}
1070
1071/// Request body for `Client591::high_value_search`. Most arrays are
1072/// "preferred" filters — empty Vec means "no preference"; populated
1073/// Vec biases the result toward matching listings but isn't strictly
1074/// enforced (591's curation overrides individual filters).
1075///
1076/// **Quirks observed live 2026-04-30:**
1077/// - Two `kind` values populate distinct curated pools: `9` (the
1078/// default, mostly residential) and `10` (a separate bucket with
1079/// different streets / `post_id`s). `0` is treated server-side as
1080/// `9`. Other values (1..8, 11+) yield empty arrays.
1081/// - The wire-side `kind: 9` here is NOT "land" (as on rent
1082/// endpoints) — it tags the curated premium pool; returned items
1083/// are mostly residentials (e.g. `"3房2廳"`).
1084/// - `section_id` filtering is non-strict (results may include
1085/// sections not in the requested list).
1086/// - `type: 2` is the only observed value (sale-side curation).
1087#[derive(Debug, Clone, Serialize)]
1088pub struct HighValueParams {
1089 pub region_id: u32,
1090 /// Curated-pool tag. Only `9` returns non-empty results today.
1091 pub kind: u32,
1092 /// `2` = sale-side curation.
1093 #[serde(rename = "type")]
1094 pub kind_type: u32,
1095 #[serde(default)]
1096 pub section_id: Vec<u32>,
1097 /// 591 sends shape codes as strings (e.g. `["2"]`), not ints.
1098 #[serde(default)]
1099 pub shape: Vec<String>,
1100 #[serde(default)]
1101 pub room: Vec<u32>,
1102 #[serde(default)]
1103 pub price: Vec<PriceRange>,
1104 #[serde(default)]
1105 pub area: Vec<AreaRange>,
1106}
1107
1108impl HighValueParams {
1109 /// Convenience constructor: every filter empty, `region_id`
1110 /// defaulted to Taipei, `kind=9` (the only kind that returns
1111 /// data), `type=2` (sale).
1112 pub fn for_region(region_id: u32) -> Self {
1113 Self {
1114 region_id,
1115 kind: 9,
1116 kind_type: 2,
1117 section_id: vec![],
1118 shape: vec![],
1119 room: vec![],
1120 price: vec![],
1121 area: vec![],
1122 }
1123 }
1124}
1125
1126/// One curated premium-listing result from `/v1/high-value/search`.
1127#[derive(Debug, Clone, Serialize, Deserialize)]
1128pub struct HighValueListing {
1129 pub post_id: u64,
1130 pub title: String,
1131 /// Listing type (`2` = sale, observed).
1132 #[serde(rename = "type")]
1133 pub kind_type: u32,
1134 /// Curated-pool tag (always `9` in observed responses).
1135 pub kind: u32,
1136 /// Total price in `unit` (typically 萬 = 10,000 NTD). Always
1137 /// populated in observed responses (verified across 39 live samples
1138 /// spanning 6 regions, 2026-04-30); deserializer accepts string-int
1139 /// for forward-compat with the SaleListData.total wire flip but
1140 /// fails loudly on null — consistent with the loud-fail policy on
1141 /// sibling required-numerics (area, room, hall, toilet).
1142 #[serde(deserialize_with = "u64_string_or_int")]
1143 pub price: u64,
1144 /// Price unit string (`"萬"`).
1145 pub unit: String,
1146 /// Area in `area_unit`.
1147 pub area: f64,
1148 /// Area unit (`"坪"`).
1149 pub area_unit: String,
1150 /// Per-坪 price (e.g. `114.1` 萬/坪).
1151 pub unit_price: f64,
1152 /// Bedrooms.
1153 pub room: u32,
1154 /// 廳 (living rooms).
1155 pub hall: u32,
1156 /// 衛 (bathrooms).
1157 pub toilet: u32,
1158 /// Pre-formatted layout (`"3房2廳1衛"`).
1159 pub layout: String,
1160 pub region_name: String,
1161 pub section_name: String,
1162 /// Street name (e.g. `"建國南路一段"`).
1163 pub street_name: String,
1164 /// Cover image URL.
1165 pub cover: String,
1166}
1167
1168#[derive(Debug, Clone, Deserialize)]
1169pub(crate) struct HighValueSearchResponse {
1170 pub status: i32,
1171 #[serde(default)]
1172 pub msg: String,
1173 #[serde(default)]
1174 pub data: Vec<HighValueListing>,
1175}
1176
1177// ---------------------------------------------------------------------------
1178// /v1/coordinate/area — GPS lat/lng → 591 region/section reverse-geocode
1179// ---------------------------------------------------------------------------
1180
1181/// 591 region+section pair resolved from GPS coordinates.
1182///
1183/// Returned by `Client591::coordinate_area`. Field semantics match
1184/// the existing `REGIONS` table — `region_id` indexes into it (1 =
1185/// Taipei), and `section_id` is the 591-internal district code
1186/// within that region (e.g. 信義區 within Taipei is `section_id: 7`).
1187#[derive(Debug, Clone, Serialize, Deserialize)]
1188pub struct CoordArea {
1189 pub region_id: u32,
1190 pub region_name: String,
1191 pub section_id: u32,
1192 pub section_name: String,
1193}
1194
1195/// Internal wire-shape wrapper for `/v1/coordinate/area`.
1196///
1197/// 591 returns two distinct `data` shapes:
1198/// - **Hit** (coords in Taiwan): `{"status":1,"data":{"area":{...}}}`
1199/// - **Miss** (off-Taiwan): `{"status":0,"msg":"坐标不在台湾范围内","data":[]}`
1200///
1201/// `data` is typed as raw `serde_json::Value` because the `[]` vs
1202/// `{area: …}` polymorphism would defeat a typed struct. The
1203/// adapter dispatches on `status` and only deserializes the hit
1204/// shape into [`CoordArea`].
1205#[derive(Debug, Clone, Deserialize)]
1206pub(crate) struct CoordResponse {
1207 pub status: i32,
1208 #[serde(default)]
1209 pub msg: String,
1210 #[serde(default)]
1211 pub data: serde_json::Value,
1212}
1213
1214// ---------------------------------------------------------------------------
1215// /v2/web/rent/detail — single rent listing detail
1216// /v1/ware/photos — categorized photo gallery
1217// ---------------------------------------------------------------------------
1218
1219/// One labelled key/value pair. Used pervasively in `RentDetail`'s
1220/// `info` / `cost.data` / `houseInfo.data` arrays — 591 wraps every
1221/// surfaced fact this way so the website can display label+value
1222/// rows without front-end string mapping.
1223#[derive(Debug, Clone, Serialize, Deserialize)]
1224pub struct LabelledValue {
1225 /// Display label (`"類型"`, `"押金"`, `"管理費"`, `"租期"`, …).
1226 pub name: String,
1227 /// Display value (`"獨立套房"`, `"500元/月"`, `"一年"`, …).
1228 pub value: String,
1229 /// Stable key for programmatic lookup (`"kind"`, `"deposit"`,
1230 /// `"manageprice"`, `"leaseTime"`, …).
1231 pub key: String,
1232}
1233
1234/// One tag attached to a rent listing (`{id: 16, value: "新上架"}`).
1235/// IDs are stable (e.g. `2` = 近捷運, `16` = 新上架, `19` = 社會住宅).
1236#[derive(Debug, Clone, Serialize, Deserialize)]
1237pub struct RentTag {
1238 pub id: u32,
1239 pub value: String,
1240}
1241
1242/// Free-form listing description block.
1243#[derive(Debug, Clone, Serialize, Deserialize)]
1244pub struct RentRemark {
1245 /// `"屋況介紹"`.
1246 #[serde(default)]
1247 pub title: String,
1248 /// Stable key (`"remark"`).
1249 #[serde(default)]
1250 pub key: String,
1251 /// Visibility flag (1 = visible).
1252 #[serde(default)]
1253 pub active: u32,
1254 /// Free-form Chinese description text from the lister. Defaults
1255 /// to empty when 591 omits it for a maintenance-state listing.
1256 #[serde(default)]
1257 pub content: String,
1258}
1259
1260/// One amenity entry within [`RentServiceTable::facility`].
1261#[derive(Debug, Clone, Serialize, Deserialize)]
1262pub struct RentServiceItem {
1263 /// Stable amenity key (`"fridge"`, `"washer"`, `"tv"`, `"cold"`,
1264 /// `"heater"`, `"bed"`, `"closet"`, `"fourth"`, `"net"`, …).
1265 pub key: String,
1266 /// Display name (`"冰箱"`, `"洗衣機"`, `"電視"`, …).
1267 pub name: String,
1268 /// 1 = provided in this rental, 0 = not provided. The wire
1269 /// response always carries every amenity in the master list,
1270 /// even those not provided — `active` is the discriminator.
1271 #[serde(default)]
1272 pub active: u32,
1273}
1274
1275/// One usage-rule entry within [`RentServiceTable::notice`].
1276/// Examples: `{key: "leaseTime", name: "最短一年"}`,
1277/// `{key: "pet", name: "不可養寵物"}`, `{key: "cook", name: "不可開伙"}`.
1278#[derive(Debug, Clone, Serialize, Deserialize)]
1279pub struct RentNoticeItem {
1280 pub key: String,
1281 pub name: String,
1282}
1283
1284/// `RentDetail.service` — provided amenities + usage rules.
1285///
1286/// Distinct shape from [`RentFactTable`] (which uses a `data` array).
1287/// `service` returns `{title, active, facility, notice}` where each
1288/// facility row carries an `active` discriminator (so callers can
1289/// filter to *actually-provided* amenities) and `notice` carries
1290/// usage rules that don't fit the cost-table model.
1291#[derive(Debug, Clone, Serialize, Deserialize)]
1292pub struct RentServiceTable {
1293 /// `"提供設備"`.
1294 #[serde(default)]
1295 pub title: String,
1296 #[serde(default)]
1297 pub active: u32,
1298 /// Master amenity list, including not-provided rows
1299 /// (`active: 0`). Filter by `active == 1` for the "what's actually
1300 /// provided" subset.
1301 #[serde(default)]
1302 pub facility: Vec<RentServiceItem>,
1303 /// Usage rules / lease constraints (lease term, pet policy,
1304 /// cooking allowed, …).
1305 #[serde(default)]
1306 pub notice: Vec<RentNoticeItem>,
1307}
1308
1309/// Listing publish state — when it went online + when it was last refreshed.
1310#[derive(Debug, Clone, Serialize, Deserialize)]
1311#[serde(rename_all = "camelCase")]
1312pub struct RentPublish {
1313 pub id: u32,
1314 /// Display name (`"新發佈"`, `"已上架"`, …).
1315 pub name: String,
1316 /// Stable key (`"new"`, `"old"`, …).
1317 pub key: String,
1318 /// `"此房屋在13小時前發佈"` etc.
1319 #[serde(default)]
1320 pub post_time: String,
1321 /// `"19分鐘內更新"` etc.
1322 #[serde(default)]
1323 pub update_time: String,
1324}
1325
1326/// Listing address with GPS coordinates.
1327#[derive(Debug, Clone, Serialize, Deserialize)]
1328pub struct RentAddress {
1329 /// Display address (`"中山區雙城街50號"`).
1330 pub data: String,
1331 /// Sometimes-redundant fuller form (591 occasionally double-prints).
1332 #[serde(default)]
1333 pub value: String,
1334 /// Latitude as wire-string (parse with `.parse::<f64>()`).
1335 pub lat: String,
1336 /// Longitude as wire-string.
1337 pub lng: String,
1338 /// Nearest transit summary (often empty — see `RentDetail.surround`
1339 /// for structured POIs).
1340 #[serde(default)]
1341 pub traffic: String,
1342 #[serde(default)]
1343 pub station: String,
1344 #[serde(default)]
1345 pub distance: String,
1346}
1347
1348/// Listing's primary contact (agent or owner).
1349///
1350/// **Wire shape is polymorphic** — verified live 2026-04-30: 591
1351/// inconsistently returns this as either a named-field object
1352/// (`{"name":"程先生","role":3,...}`) OR an array of K/V pairs
1353/// (`[{"key":"name","value":"程先生"},{"key":"role","value":3},...]`).
1354/// Both forms carry the same data; the discriminator appears
1355/// load-balancer- or A/B-driven.
1356///
1357/// To handle both, this is stored as a raw `serde_json::Value` and
1358/// queried via accessor methods (`name()`, `role_name()`, `mobile()`,
1359/// etc.) that dispatch on the wire shape.
1360pub type RentLinkInfo = serde_json::Value;
1361
1362/// Helpers for reading [`RentLinkInfo`] under either wire shape.
1363pub trait RentLinkInfoExt {
1364 /// Read a string field by key (e.g. `"name"`, `"mobile"`,
1365 /// `"roleName"`). Returns `None` if absent or the wire-side value
1366 /// isn't a string.
1367 fn link_str(&self, key: &str) -> Option<&str>;
1368 /// Read a u64 field by key (e.g. `"uid"`, `"imUid"`, `"shopId"`).
1369 fn link_u64(&self, key: &str) -> Option<u64>;
1370 /// Read a u32 field by key (e.g. `"role"`, `"isAgent"`).
1371 fn link_u32(&self, key: &str) -> Option<u32>;
1372}
1373
1374impl RentLinkInfoExt for RentLinkInfo {
1375 fn link_str(&self, key: &str) -> Option<&str> {
1376 if let Some(obj) = self.as_object() {
1377 // Object form: direct key lookup.
1378 obj.get(key).and_then(|v| v.as_str())
1379 } else if let Some(arr) = self.as_array() {
1380 // Array form: scan for {key: <key>, value: ...}.
1381 arr.iter()
1382 .find(|e| e.get("key").and_then(|k| k.as_str()) == Some(key))
1383 .and_then(|e| e.get("value").and_then(|v| v.as_str()))
1384 } else {
1385 None
1386 }
1387 }
1388 fn link_u64(&self, key: &str) -> Option<u64> {
1389 if let Some(obj) = self.as_object() {
1390 obj.get(key).and_then(|v| v.as_u64())
1391 } else if let Some(arr) = self.as_array() {
1392 arr.iter()
1393 .find(|e| e.get("key").and_then(|k| k.as_str()) == Some(key))
1394 .and_then(|e| e.get("value").and_then(|v| v.as_u64()))
1395 } else {
1396 None
1397 }
1398 }
1399 fn link_u32(&self, key: &str) -> Option<u32> {
1400 self.link_u64(key).and_then(|v| u32::try_from(v).ok())
1401 }
1402}
1403
1404/// One section in the cost / houseInfo / similar fact-tables.
1405#[derive(Debug, Clone, Serialize, Deserialize)]
1406pub struct RentFactTable {
1407 pub title: Option<String>,
1408 /// Visibility flag (1 = active/visible).
1409 #[serde(default)]
1410 pub active: u32,
1411 /// Labelled key/value rows.
1412 #[serde(default)]
1413 pub data: Vec<LabelledValue>,
1414}
1415
1416/// One nearby POI within `RentDetail.surround.data[].children`.
1417#[derive(Debug, Clone, Serialize, Deserialize)]
1418#[serde(rename_all = "camelCase")]
1419pub struct RentSurroundPoi {
1420 /// `"subway"`, `"bus"`, `"school"`, `"market"`, …
1421 #[serde(rename = "type")]
1422 pub kind: String,
1423 pub name: String,
1424 /// Walking distance in metres.
1425 pub distance: u64,
1426 /// `"距房屋約558公尺"`.
1427 #[serde(default)]
1428 pub distance_txt: String,
1429}
1430
1431/// One category of surrounding POIs (`{name: "交通", key: "traffic", children: [...]}`).
1432#[derive(Debug, Clone, Serialize, Deserialize)]
1433pub struct RentSurroundCategory {
1434 /// Display name (`"交通"`, `"教育"`, `"生活"`, …).
1435 pub name: String,
1436 /// Stable key (`"traffic"`, `"education"`, `"life"`).
1437 pub key: String,
1438 #[serde(default)]
1439 pub children: Vec<RentSurroundPoi>,
1440}
1441
1442/// Surrounding POIs block — categorized like `NewhouseSurroundingFacility`
1443/// but with a different wire shape (rent-side uses categorized children
1444/// with `key`/`name`; newhouse-side uses flat top-level keys).
1445#[derive(Debug, Clone, Serialize, Deserialize)]
1446pub struct RentSurround {
1447 pub title: String,
1448 pub key: String,
1449 pub address: String,
1450 /// Project lat/lng as wire-strings.
1451 pub lat: String,
1452 pub lng: String,
1453 #[serde(default)]
1454 pub data: Vec<RentSurroundCategory>,
1455}
1456
1457/// Single-listing detail from `bff-house.591.com.tw/v2/web/rent/detail`.
1458///
1459/// Curated subset of the wire `data` block (~30 top-level fields →
1460/// ~17 surfaced). Skipped fields: `gtm_detail_data`, `meta`, `navData`,
1461/// `shareInfo`, `favData`, `containCost`, `relieved`, `version`,
1462/// `information` (alt of `info`) — telemetry / SEO / rendering
1463/// metadata that adds noise without value.
1464#[derive(Debug, Clone, Serialize, Deserialize)]
1465#[serde(rename_all = "camelCase")]
1466pub struct RentDetail {
1467 pub title: String,
1468 /// Display price (`"17,800"` — note the comma).
1469 pub price: String,
1470 /// Price unit (`"元/月"`).
1471 pub price_unit: String,
1472 /// Deposit display (`"押金面議"` or `"2個月"`).
1473 pub deposit: String,
1474 /// Pre-formatted headline (`"17,800元/月"`).
1475 pub head_info: String,
1476 pub address: RentAddress,
1477 pub region_id: u32,
1478 pub section_id: u32,
1479 /// House-kind code: 1=整層, 2=獨立套房, 3=分租套房, 8=雅房, 6=辦公, 24=車位.
1480 pub kind: u32,
1481 /// Listing status (`"open"`, `"closed"`).
1482 pub status: String,
1483 /// Quick-fact rows (類型 / 使用坪數 / 樓層 / 型態).
1484 #[serde(default)]
1485 pub info: Vec<LabelledValue>,
1486 /// Cost breakdown (押金 / 管理費 / 仲介費).
1487 pub cost: RentFactTable,
1488 /// Detailed house attributes (租期, 入住, 身份, 法定用途, …).
1489 pub house_info: RentFactTable,
1490 /// Tenant preferences (養寵物 / 開伙 / 性別偏好 / …) — same fact-table shape.
1491 pub preference: RentFactTable,
1492 /// Provided amenities (`facility[]`) + usage rules (`notice[]`).
1493 /// Distinct shape from the other fact-tables — see [`RentServiceTable`].
1494 pub service: RentServiceTable,
1495 /// Surrounding POIs categorized by traffic/education/life.
1496 pub surround: RentSurround,
1497 pub tags: Vec<RentTag>,
1498 /// Publish/refresh metadata.
1499 pub publish: RentPublish,
1500 /// Free-form listing description (`{title, key, active, content}`).
1501 pub remark: RentRemark,
1502 /// Primary contact (agent / owner).
1503 pub link_info: RentLinkInfo,
1504}
1505
1506#[derive(Debug, Clone, Deserialize)]
1507pub(crate) struct RentDetailResponse {
1508 pub status: i32,
1509 #[serde(default)]
1510 pub msg: String,
1511 pub data: Option<RentDetail>,
1512}
1513
1514/// One photo within a [`RentPhotoGroup`].
1515#[derive(Debug, Clone, Serialize, Deserialize)]
1516#[serde(rename_all = "camelCase")]
1517pub struct RentPhoto {
1518 pub photo_id: u64,
1519 /// 1000px-wide watermarked variant (the gallery image).
1520 pub photo: String,
1521 /// 600px-tall variant.
1522 #[serde(default)]
1523 pub orig_photo: String,
1524 /// 400×300 thumbnail.
1525 #[serde(default)]
1526 pub thumb_photo: String,
1527 /// 1 if this is the cover photo, 0 otherwise.
1528 #[serde(default)]
1529 pub is_cover: u32,
1530 /// Purpose code (10 = cover, 12 = bedroom, etc. — see
1531 /// `RentPhotosData.purposes` for the lookup table per response).
1532 #[serde(default)]
1533 pub purpose: u32,
1534 /// Optional caption.
1535 #[serde(default)]
1536 pub note: String,
1537 /// Wire type code (3 observed).
1538 #[serde(default, rename = "type")]
1539 pub kind: u32,
1540}
1541
1542/// One bucket of photos within `/v1/ware/photos`'s `data.list`.
1543#[derive(Debug, Clone, Serialize, Deserialize)]
1544pub struct RentPhotoGroup {
1545 /// Group key (`"picture"`, `"floor"`, `"environment"`, …).
1546 pub key: String,
1547 #[serde(default)]
1548 pub items: Vec<RentPhoto>,
1549}
1550
1551#[derive(Debug, Clone, Deserialize)]
1552pub(crate) struct RentPhotosResponse {
1553 pub status: i32,
1554 #[serde(default)]
1555 pub msg: String,
1556 pub data: Option<RentPhotosData>,
1557}
1558
1559#[derive(Debug, Clone, Deserialize)]
1560pub(crate) struct RentPhotosData {
1561 #[serde(default)]
1562 pub list: Vec<RentPhotoGroup>,
1563}
1564
1565// ---------------------------------------------------------------------------
1566// /v1/touch/sale/detail — single sale listing detail
1567// /v2/web/sale/similar-wares — similar sale listings
1568// ---------------------------------------------------------------------------
1569
1570/// Single sale listing detail from `bff-house.591.com.tw/v1/touch/sale/detail`.
1571///
1572/// Curated subset of the wire `data` block (~150 fields → ~30 surfaced).
1573/// Most fields are wire-strings (591 stringly-types numeric IDs and
1574/// coordinates on this endpoint — `region_id`, `room`, `lat`/`lng`,
1575/// `houseage` etc. all arrive as strings). To stay loyal to the wire
1576/// without forcing risky parses, identifiers + coords stay as `String`
1577/// — callers `.parse::<f64>()` / `.parse::<u32>()` as needed.
1578///
1579/// **The wire `id` field has an `S` prefix** (e.g. `"S19599759"`).
1580/// Pass the bare numeric ID to `Client591::sale_detail` and the
1581/// adapter prepends `S` for you.
1582#[derive(Debug, Clone, Serialize, Deserialize)]
1583pub struct SaleDetail {
1584 /// Wire ID with `S` prefix (e.g. `"S19599759"`).
1585 pub id: String,
1586 pub title: String,
1587 /// Pre-formatted price (`"1,988萬元"`).
1588 pub price: String,
1589 /// Numeric price as a wire-string (`"1988"`, in 萬).
1590 pub price_value: String,
1591 /// Per-坪 price string (`"86.02萬/坪"`).
1592 pub unitprice: String,
1593 /// Pre-formatted area (`"23.111坪"`).
1594 pub area: String,
1595 /// Numeric area as wire-string (`"23.111"`).
1596 pub area_value: String,
1597 /// Pre-formatted layout (`"3房2廳3衛"`).
1598 pub layout: String,
1599 /// Number of bedrooms as wire-string (`"3"`).
1600 pub room: String,
1601 /// Number of living rooms as wire-string.
1602 pub hall: String,
1603 /// Number of bathrooms as wire-string.
1604 pub toilet: String,
1605 /// Display kind (`"住宅"`, `"商業"`, `"辦公"`, …).
1606 pub kind: String,
1607 /// Numeric kind code as wire-string (`"9"` = residential, …).
1608 pub kind_id: String,
1609 pub region: String,
1610 /// Wire-string region ID — `.parse::<u32>()` for numeric.
1611 pub region_id: String,
1612 pub section: String,
1613 pub section_id: String,
1614 /// Display address (often empty for owner-direct rentals — see
1615 /// `lat`/`lng` for coordinates).
1616 #[serde(default)]
1617 pub addr: String,
1618 /// Latitude as wire-string.
1619 pub lat: String,
1620 /// Longitude as wire-string.
1621 pub lng: String,
1622 /// Age string (`"56年"`).
1623 #[serde(default)]
1624 pub age: String,
1625 /// Numeric age as wire-string (`"56"`).
1626 #[serde(default)]
1627 pub houseage: String,
1628 /// Building shape (`"透天厝"`, `"電梯大樓"`, `"公寓"`, …).
1629 #[serde(default)]
1630 pub shape: String,
1631 /// Decoration level (`"中檔裝潢"`, `"無"`, …).
1632 #[serde(default)]
1633 pub fitment: String,
1634 /// Direction (`"東南"`, `"南"`, …).
1635 #[serde(default)]
1636 pub direction: String,
1637 /// Lift-availability flag as wire-string (`"0"` / `"1"`).
1638 #[serde(default)]
1639 pub lift: String,
1640 /// Pre-formatted parking text (`"無"`, `"1個"`, …).
1641 #[serde(default)]
1642 pub parking: String,
1643 /// Main usable area (`"20.69坪"`).
1644 #[serde(default)]
1645 pub mainarea: String,
1646 /// Management fee text (`"500元/月"`, `"無"`, …). Sentinel
1647 /// `"99999"` on the numeric `manageprice` indicates "not applicable".
1648 #[serde(default)]
1649 pub managefee: String,
1650 /// Unix timestamp of post (as wire-string).
1651 pub posttime: String,
1652 /// Linked community name (empty for owner-direct without community).
1653 #[serde(default)]
1654 pub community: String,
1655 /// Linked community ID (empty string for unlinked).
1656 #[serde(default)]
1657 pub community_id: String,
1658 /// Listing's primary contact (the `linkman` / agent / owner).
1659 pub linkman: String,
1660 /// Mobile phone (`"0965-109-089"`).
1661 #[serde(default)]
1662 pub mobile: String,
1663 /// Landline.
1664 #[serde(default)]
1665 pub telephone: String,
1666 /// Email address.
1667 #[serde(default)]
1668 pub email: String,
1669 /// Display contact role (`"仲介"` / `"屋主"`).
1670 pub identity: String,
1671 /// Agent shop / company.
1672 #[serde(default)]
1673 pub company_name: String,
1674 /// Agent certificate type (`"Middleman"` etc.).
1675 #[serde(default)]
1676 pub certificate_type: String,
1677}
1678
1679#[derive(Debug, Clone, Deserialize)]
1680pub(crate) struct SaleDetailResponse {
1681 pub status: i32,
1682 /// `Option<String>` because 591 returns `"msg": null` on this
1683 /// endpoint when status=1 — `#[serde(default)]` alone wouldn't
1684 /// cover the explicit-null case (it only covers missing keys).
1685 #[serde(default)]
1686 pub msg: Option<String>,
1687 pub data: Option<SaleDetail>,
1688}
1689
1690/// One similar sale listing from `bff-house.591.com.tw/v2/web/sale/similar-wares`.
1691/// Wire is heavily stringly-typed (every numeric is a string) — the
1692/// type stays loyal to that and lets callers parse as needed.
1693#[derive(Debug, Clone, Serialize, Deserialize)]
1694pub struct SaleSimilarWare {
1695 /// Wire-string listing type code (`"2"` = sale).
1696 #[serde(rename = "type")]
1697 pub kind_type: String,
1698 pub post_id: String,
1699 pub title: String,
1700 /// Pre-formatted price (`"1980"` in 萬, no thousands separator).
1701 pub price: String,
1702 /// Pre-formatted area (`"20.808"` in 坪).
1703 pub area: String,
1704 /// Wire-string house-kind code.
1705 pub kind: String,
1706 /// Layout summary (`"6房6衛"`).
1707 #[serde(default)]
1708 pub room: String,
1709 pub section_name: String,
1710 /// Cover image URL.
1711 pub photo_url: String,
1712 /// Marketing tag string (often empty).
1713 #[serde(default)]
1714 pub tag: String,
1715 /// `"vip"`/`"refresh"`/etc. — empty when no special boost.
1716 #[serde(default)]
1717 pub similar_type: String,
1718 /// 1 = boosted/premium listing.
1719 #[serde(default)]
1720 pub is_vip: String,
1721 /// 1 = recently refreshed.
1722 #[serde(default)]
1723 pub is_refresh: String,
1724 /// 1 = combined / bundled ad slot (591 groups some listings for
1725 /// joint promotion). Useful for filtering boost-grouped duplicates.
1726 #[serde(default)]
1727 pub is_combine: String,
1728}
1729
1730#[derive(Debug, Clone, Deserialize)]
1731pub(crate) struct SaleSimilarWaresResponse {
1732 pub status: i32,
1733 /// `Option<String>` for the same reason as `SaleDetailResponse.msg` —
1734 /// 591 occasionally returns `"msg": null` on these envelopes.
1735 #[serde(default)]
1736 pub msg: Option<String>,
1737 #[serde(default)]
1738 pub data: Vec<SaleSimilarWare>,
1739}
1740
1741// ---------------------------------------------------------------------------
1742// Internal response wrappers (not exposed publicly)
1743// ---------------------------------------------------------------------------
1744
1745#[derive(Deserialize)]
1746pub(crate) struct HotResponse {
1747 pub status: i32,
1748 pub data: Option<Vec<Community>>,
1749}
1750
1751#[derive(Deserialize)]
1752pub(crate) struct NearbyResponse {
1753 pub status: i32,
1754 pub data: Option<Vec<NearbyApiItem>>,
1755}
1756
1757/// 591's nearby payload uses snake_case keys that don't match
1758/// `NearbyCommunity` exactly (e.g. `regionid`/`sectionid` vs
1759/// `region_id`/`section_id`). This wrapper deserializes the wire
1760/// shape verbatim; `Client591::nearby` maps to `NearbyCommunity`.
1761///
1762/// **Maintenance:** when adding a new field to `NearbyCommunity`,
1763/// add the matching field here AND extend the `From` impl below.
1764/// Alternative would be `#[serde(rename = "regionid")]` directly on
1765/// `NearbyCommunity`, but the wrapper-pattern matches the rest of
1766/// this crate's response types.
1767#[derive(Deserialize)]
1768pub(crate) struct NearbyApiItem {
1769 pub id: u64,
1770 pub name: String,
1771 pub regionid: u32,
1772 pub sectionid: u32,
1773 pub region: String,
1774 pub section: String,
1775 pub price_unit: PriceValue,
1776 pub address: String,
1777 pub full_address: String,
1778 pub age: String,
1779 pub house_holds: String,
1780 pub build_purpose: String,
1781 pub distance: String,
1782 // TODO: `sale_num` is a sibling count-field on the same wire
1783 // shape as `min_price`. 591 has historically type-flipped count
1784 // fields (saw min_price flip from int → string in early 2026).
1785 // If `sale_num` flips next, wrap it the same way; the live test
1786 // `nearby_eat_returns_taipei_neighbours` will catch it.
1787 pub sale_num: u32,
1788 /// 591 returns this as either a JSON int or a JSON string
1789 /// depending on the day. Custom deserializer accepts both.
1790 #[serde(deserialize_with = "u64_string_or_int")]
1791 pub min_price: u64,
1792}
1793
1794impl From<NearbyApiItem> for NearbyCommunity {
1795 fn from(v: NearbyApiItem) -> Self {
1796 NearbyCommunity {
1797 id: v.id,
1798 name: v.name,
1799 region_id: v.regionid,
1800 section_id: v.sectionid,
1801 region: v.region,
1802 section: v.section,
1803 price_unit: v.price_unit,
1804 address: v.address,
1805 full_address: v.full_address,
1806 age: v.age,
1807 house_holds: v.house_holds,
1808 build_purpose: v.build_purpose,
1809 distance: v.distance,
1810 sale_num: v.sale_num,
1811 min_price: v.min_price,
1812 }
1813 }
1814}
1815
1816#[derive(Deserialize)]
1817pub(crate) struct DetailResponse {
1818 pub status: i32,
1819 pub data: Option<DetailData>,
1820}
1821
1822#[derive(Deserialize)]
1823pub(crate) struct SaleListResponse {
1824 pub status: i32,
1825 pub data: Option<SaleListData>,
1826}
1827
1828#[derive(Deserialize, Default)]
1829pub(crate) struct SaleListData {
1830 /// 591 returns this as a string (`"23899"`) on this endpoint —
1831 /// not an int like elsewhere. `u32_string_or_int` handles both.
1832 #[serde(default, deserialize_with = "u32_string_or_int_default")]
1833 pub total: u32,
1834 /// Raw items — 591 mixes paid newhouse ads (with a different
1835 /// schema) into the sale-listing array. We deserialize per-item
1836 /// in the adapter and drop ads.
1837 #[serde(default)]
1838 pub house_list: Vec<serde_json::Value>,
1839}
1840
1841/// `u64` variant of [`u32_string_or_int`]. Used for `NearbyApiItem::
1842/// min_price` — 591 sometimes serializes this as a string (`"2380"`),
1843/// sometimes as an integer (`2380`); empirically the wire form
1844/// changed without notice in early 2026.
1845fn u64_string_or_int<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
1846 let v = serde_json::Value::deserialize(d)?;
1847 match v {
1848 serde_json::Value::String(s) => s.parse().map_err(serde::de::Error::custom),
1849 serde_json::Value::Number(n) => n
1850 .as_u64()
1851 .ok_or_else(|| serde::de::Error::custom(format!("u64 out of range: {n}"))),
1852 other => Err(serde::de::Error::custom(format!(
1853 "expected string or integer for u64, got {other:?}"
1854 ))),
1855 }
1856}
1857
1858/// Like [`u64_string_or_int`] but also accepts `null` (returns 0) so
1859/// it composes with `#[serde(default)]`.
1860fn u64_string_or_int_default<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
1861 let v = serde_json::Value::deserialize(d)?;
1862 match v {
1863 serde_json::Value::Null => Ok(0),
1864 serde_json::Value::String(s) => s.parse().map_err(serde::de::Error::custom),
1865 serde_json::Value::Number(n) => n
1866 .as_u64()
1867 .ok_or_else(|| serde::de::Error::custom(format!("u64 out of range: {n}"))),
1868 other => Err(serde::de::Error::custom(format!(
1869 "expected null/string/integer, got {other:?}"
1870 ))),
1871 }
1872}
1873
1874/// Like [`u32_string_or_int`] but also accepts `null` (returns 0) so
1875/// it composes with `#[serde(default)]`.
1876fn u32_string_or_int_default<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
1877 let v = serde_json::Value::deserialize(d)?;
1878 match v {
1879 serde_json::Value::Null => Ok(0),
1880 serde_json::Value::String(s) => s.parse().map_err(serde::de::Error::custom),
1881 serde_json::Value::Number(n) => n
1882 .as_u64()
1883 .and_then(|n| u32::try_from(n).ok())
1884 .ok_or_else(|| serde::de::Error::custom(format!("u32 out of range: {n}"))),
1885 other => Err(serde::de::Error::custom(format!(
1886 "expected null/string/integer, got {other:?}"
1887 ))),
1888 }
1889}
1890
1891#[derive(Deserialize)]
1892pub(crate) struct NewhouseListResponse {
1893 pub status: i32,
1894 pub data: Option<NewhouseListData>,
1895}
1896
1897#[derive(Deserialize)]
1898pub(crate) struct NewhouseListData {
1899 pub total: u32,
1900 pub online_total: u32,
1901 pub page: u32,
1902 pub per_page: u32,
1903 pub total_page: u32,
1904 /// Raw items — 591 mixes empty placeholder slots into the array.
1905 /// Per-item `from_value` + filter_map drops them.
1906 pub items: Vec<serde_json::Value>,
1907}
1908
1909#[derive(Deserialize)]
1910pub(crate) struct CommunityRankResponse {
1911 pub status: i32,
1912 pub data: Option<CommunityRankApiData>,
1913}
1914
1915#[derive(Deserialize)]
1916pub(crate) struct CommunityRankApiData {
1917 pub price_data: CommunityRankSlot,
1918 pub sale_data: CommunityRankSlot,
1919}
1920
1921#[derive(Deserialize)]
1922pub(crate) struct CommunityRankSlot {
1923 pub data: Vec<CommunityRankItem>,
1924 pub time: String,
1925}
1926
1927#[derive(Deserialize)]
1928pub(crate) struct DetailData {
1929 pub community: Option<CommunityDetail>,
1930 pub price: Option<PriceData>,
1931 pub sale: Option<SaleData>,
1932}
1933
1934#[derive(Deserialize)]
1935pub(crate) struct PriceData {
1936 pub items: Vec<PriceRecord>,
1937}
1938
1939#[derive(Deserialize)]
1940pub(crate) struct SaleData {
1941 pub total: u32,
1942 pub rooms: Vec<SaleRoom>,
1943}
1944
1945#[derive(Deserialize)]
1946pub(crate) struct SaleRoom {
1947 pub items: Vec<SaleListing>,
1948}