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 <tr> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-18 12:05:13 +00:00
commit 30afcc109d

View file

@ -151,13 +151,17 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
if html.to_ascii_lowercase().contains("invalid stock code") { if html.to_ascii_lowercase().contains("invalid stock code") {
return Ok(IpotPage::Invalid); return Ok(IpotPage::Invalid);
} }
let Some((body_start, body_end)) = html.find("<tbody>").and_then(|start| { // Tags are matched case-insensitively on a lowercased copy; ASCII
html[start..] // lowercasing keeps byte offsets, so slices of it index `html` too.
let lower = html.to_ascii_lowercase();
let body = lower.find("<tbody>").and_then(|start| {
lower[start..]
.find("</tbody>") .find("</tbody>")
.map(|len| (start, start + len)) .map(|len| (start + "<tbody>".len(), start + len))
}) else { });
let Some((body_start, body_end)) = body.filter(|_| lower.matches("<tbody").count() == 1) else {
return Err(IdxError::ParseError( return Err(IdxError::ParseError(
"ipot broker summary: no table in the page".to_string(), "ipot broker summary: no single table in the page".to_string(),
)); ));
}; };
let mut raw = BrokerSummaryRaw { let mut raw = BrokerSummaryRaw {
@ -172,19 +176,31 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
value value
}) })
}; };
// A skipped row would shift every later broker up a rank, so a row of // A skipped or merged row would shift brokers between ranks, so the body
// another shape, a rank out of order, or a broker after a side's blank // must be whole rows only, each one `<tr>` of nine closed cells, ranked
// rows rejects the page. // 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 buyers_ended, mut sellers_ended) = (false, false);
let mut rank = 0; let mut rank = 0;
for piece in html[body_start..body_end].split("</tr>") { let mut at = body_start;
// Only the space after the last row has no row in it. while at < body_end {
if !piece.contains("<tr") && !piece.contains("<td") { let rest = &lower[at..body_end];
continue; if rest.trim().is_empty() {
break;
} }
let row = cells(piece); let end = rest
.find("</tr>")
.map_or(rest.len(), |end| end + "</tr>".len());
let piece = &rest[..end];
let original = &html[at..at + end];
at += end;
rank += 1; rank += 1;
if row.len() != 9 || row[4].text.parse::<u32>().ok() != Some(rank) { let whole_row = piece.trim_start().starts_with("<tr")
&& piece.ends_with("</tr>")
&& piece.matches("<tr").count() == 1
&& piece.matches("<td").count() == 9
&& piece.matches("</td>").count() == 9;
let row = cells(original);
if !whole_row || row.len() != 9 || row[4].text.parse::<u32>().ok() != Some(rank) {
return Err(IdxError::ParseError(format!( return Err(IdxError::ParseError(format!(
"ipot broker summary: unexpected row {rank} ({} cells, rank {:?})", "ipot broker summary: unexpected row {rank} ({} cells, rank {:?})",
row.len(), row.len(),
@ -357,11 +373,28 @@ mod tests {
let unclosed = DAY[..body].to_string() let unclosed = DAY[..body].to_string()
+ &DAY[body..first_row_end].replace("</td>", "") + &DAY[body..first_row_end].replace("</td>", "")
+ &DAY[first_row_end..]; + &DAY[first_row_end..];
// Rank 9 left open and rank 10 without cell ends: rank 10 would
// vanish into rank 9.
let rows: Vec<usize> = DAY[body..]
.match_indices("</tr>")
.map(|(at, _)| body + at)
.collect();
let merged = DAY[..rows[8]].to_string()
+ &DAY[rows[8] + "</tr>".len()..rows[9]].replace("</td>", "")
+ &DAY[rows[9]..];
// An extra row in uppercase tags before rank 5.
let rank5 = DAY[..rows[4]].rfind("<tr>").unwrap();
let extra_row = DAY[rank5..rows[4] + "</tr>".len()]
.replace("<tr>", "<TR>")
.replace("<td", "<TD");
let uppercase = DAY[..rank5].to_string() + &extra_row + &DAY[rank5..];
for page in [ for page in [
short_row, short_row,
skipped_rank, skipped_rank,
gap, gap,
unclosed, unclosed,
merged,
uppercase,
"</tbody><tbody>".to_string(), "</tbody><tbody>".to_string(),
] { ] {
assert!( assert!(