Skip to main content

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}