mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
106590d151
commit
30afcc109d
1 changed files with 47 additions and 14 deletions
|
|
@ -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!(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue