From 3c5f9bb8a403981e026f1fa1d530d59e71920247 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 14:25:45 +0000 Subject: [PATCH] fix(core): an IPOT page without a readable date range is rejected Ancestor classes compare as sets (repeats removed too), and a page whose echoed range can't be read is a parse error rather than a summary with no dates, so the parser returns the exact range or nothing (Codex re-check, round 13). The broker layer already rejected missing dates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-core/src/api/ipot/parse.rs | 37 +++++++++++++++++---------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/crates/idx-core/src/api/ipot/parse.rs b/crates/idx-core/src/api/ipot/parse.rs index 21c60a7..aed3216 100644 --- a/crates/idx-core/src/api/ipot/parse.rs +++ b/crates/idx-core/src/api/ipot/parse.rs @@ -279,8 +279,11 @@ fn placed(element: ElementRef, path: &[(&str, &str)]) -> bool { let mut found: Vec<&str> = class_tokens(ancestor.attr("class").unwrap_or_default()).collect(); let mut wanted: Vec<&str> = class_tokens(class).collect(); + // As sets: order, spacing and repeats don't change the classes. found.sort_unstable(); + found.dedup(); wanted.sort_unstable(); + wanted.dedup(); ancestor.value().name() == *name && found == wanted }) } @@ -300,9 +303,8 @@ fn input_date(input: ElementRef) -> Option { } /// The range the page echoes: IPOT's start and end date inputs, in that -/// order, each with its id and name where IPOT puts it. Either missing, -/// repeated or out of place gives no dates. -fn echoed_dates(page: &Html) -> (Option, Option) { +/// order, each with its id and name where IPOT puts it, both readable. +fn echoed_dates(page: &Html) -> Option<(NaiveDate, NaiveDate)> { let inputs: Vec = page .root_element() .descendent_elements() @@ -320,8 +322,8 @@ fn echoed_dates(page: &Html) -> (Option, Option) { && placed(*input, &INPUT_PATH) }); match inputs[..] { - [start, end] if in_place => (input_date(start), input_date(end)), - _ => (None, None), + [start, end] if in_place => Some((input_date(start)?, input_date(end)?)), + _ => None, } } @@ -414,10 +416,12 @@ pub fn parse_broker_summary(html: &str) -> Result { if names != ["thead", "tbody", "tfoot"] { return Err(parse_error("unexpected table parts")); } - let (from, to) = echoed_dates(&page); + // Without the range it applied, a page's figures could be any session's. + let (from, to) = + echoed_dates(&page).ok_or_else(|| parse_error("no readable echoed date range"))?; let mut raw = BrokerSummaryRaw { - from, - to, + from: Some(from), + to: Some(to), ..BrokerSummaryRaw::default() }; let mut rounded = false; @@ -866,9 +870,7 @@ mod tests { ), ]; for (what, page) in dates_shown_otherwise { - if let Some(raw) = read(&page) { - assert_eq!(raw.from, None, "{what}"); - } + assert!(read(&page).is_none(), "{what}"); } // Ways to hide or relabel what IPOT shows, all outside its markup. let hidden = [ @@ -965,8 +967,17 @@ mod tests { .replacen("startDateBrokerSummary", "TMP", 1) .replacen("endDateBrokerSummary", "startDateBrokerSummary", 1) .replacen("TMP", "endDateBrokerSummary", 1); - let raw = read(&swapped).unwrap(); - assert_eq!((raw.from, raw.to), (None, None), "date inputs swapped"); + assert!(read(&swapped).is_none(), "date inputs swapped"); + let repeated_class = DAY.replacen( + "class=\"col col-xs-5 col-sm-3\"", + "class=\"col col col-xs-5 col-sm-3\"", + 1, + ); + assert_eq!( + read(&repeated_class), + Some(original.clone()), + "a repeated ancestor class" + ); // Rank 10's buyer AK, in the last row, so no broker follows it. let last_ak = DAY.rfind(">AK<").unwrap(); let blank_code_with_figures =