From 30afcc109db47288b7ccaacd407a03440b8d44ac Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 12:05:13 +0000 Subject: [PATCH] fix(core): read the IPOT table body as whole rows only The body (one tbody, tags matched case-insensitively) must be whole rows: each one holding nine opened and nine closed cells, ranked in order, with nothing else between rows. A row with missing close tags no longer merges into its neighbour, and an uppercase row is no longer skipped (Codex re-check). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-core/src/api/ipot/parse.rs | 61 +++++++++++++++++++++------ 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/crates/idx-core/src/api/ipot/parse.rs b/crates/idx-core/src/api/ipot/parse.rs index 74763e9..1b97ef1 100644 --- a/crates/idx-core/src/api/ipot/parse.rs +++ b/crates/idx-core/src/api/ipot/parse.rs @@ -151,13 +151,17 @@ pub fn parse_broker_summary(html: &str) -> Result { if html.to_ascii_lowercase().contains("invalid stock code") { return Ok(IpotPage::Invalid); } - let Some((body_start, body_end)) = html.find("").and_then(|start| { - html[start..] + // Tags are matched case-insensitively on a lowercased copy; ASCII + // lowercasing keeps byte offsets, so slices of it index `html` too. + let lower = html.to_ascii_lowercase(); + let body = lower.find("").and_then(|start| { + lower[start..] .find("") - .map(|len| (start, start + len)) - }) else { + .map(|len| (start + "".len(), start + len)) + }); + let Some((body_start, body_end)) = body.filter(|_| lower.matches(" Result { value }) }; - // A skipped row would shift every later broker up a rank, so a row of - // another shape, a rank out of order, or a broker after a side's blank - // rows rejects the page. + // A skipped or merged row would shift brokers between ranks, so the body + // must be whole rows only, each one `` of nine closed cells, ranked + // in order, and a broker after a side's blank rows rejects the page. let (mut buyers_ended, mut sellers_ended) = (false, false); let mut rank = 0; - for piece in html[body_start..body_end].split("") { - // Only the space after the last row has no row in it. - if !piece.contains("") + .map_or(rest.len(), |end| end + "".len()); + let piece = &rest[..end]; + let original = &html[at..at + end]; + at += end; rank += 1; - if row.len() != 9 || row[4].text.parse::().ok() != Some(rank) { + let whole_row = piece.trim_start().starts_with("") + && piece.matches("").count() == 9; + let row = cells(original); + if !whole_row || row.len() != 9 || row[4].text.parse::().ok() != Some(rank) { return Err(IdxError::ParseError(format!( "ipot broker summary: unexpected row {rank} ({} cells, rank {:?})", row.len(), @@ -357,11 +373,28 @@ mod tests { let unclosed = DAY[..body].to_string() + &DAY[body..first_row_end].replace("", "") + &DAY[first_row_end..]; + // Rank 9 left open and rank 10 without cell ends: rank 10 would + // vanish into rank 9. + let rows: Vec = DAY[body..] + .match_indices("") + .map(|(at, _)| body + at) + .collect(); + let merged = DAY[..rows[8]].to_string() + + &DAY[rows[8] + "".len()..rows[9]].replace("", "") + + &DAY[rows[9]..]; + // An extra row in uppercase tags before rank 5. + let rank5 = DAY[..rows[4]].rfind("").unwrap(); + let extra_row = DAY[rank5..rows[4] + "".len()] + .replace("", "") + .replace("".to_string(), ] { assert!(