mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
fix(core): accept only the exact markup IPOT serves in its table body
Replace the tag scanner with a strict grammar: whole <tr> rows of nine <td> cells, each holding text or one <span>, attributes as name="value" without quotes or angle brackets in the value, and no character references besides . Markup in that form reads the same in any HTML parser, so a quoted '>', '</tr >', '<td-x' or '2' can no longer make the parser see other cells than the page shows; such pages are rejected (Codex re-check, round 4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
parent
186d9557d6
commit
cecfbab235
1 changed files with 91 additions and 26 deletions
|
|
@ -131,23 +131,78 @@ fn broker_kind(class: Option<&str>) -> Option<BrokerKind> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `body` (lowercased) holds only `tr`, `td` and `span` tags, each
|
/// Whether `body` (lowercased) is exactly the markup IPOT serves, which
|
||||||
/// closed before the next `<`: no comments, other elements, or markup
|
/// reads the same in any HTML parser:
|
||||||
/// inside attributes that the row checks could miscount.
|
///
|
||||||
fn plain_markup(body: &str) -> bool {
|
/// ```text
|
||||||
body.match_indices('<').all(|(at, _)| {
|
/// body := ws (row ws)*
|
||||||
let tag = &body[at + 1..];
|
/// row := "<tr>" ws (cell ws){9} "</tr>"
|
||||||
let Some(end) = tag.find('>') else {
|
/// cell := <td attrs> ws (<span attrs> text "</span>" | text) ws "</td>"
|
||||||
|
/// attrs := (" " name '="' value '"')* value without " < >
|
||||||
|
/// text := no "<", and no "&" except " "
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Anything else (comments, other tags, unclosed or stray tags, odd
|
||||||
|
/// spacing or quoting inside tags) rejects the page rather than risk
|
||||||
|
/// reading cells differently from how the page shows them.
|
||||||
|
fn canonical_rows(body: &str) -> bool {
|
||||||
|
let mut rest = body.trim_start();
|
||||||
|
while !rest.is_empty() {
|
||||||
|
let Some(after) = canonical_row(rest) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let inner = &tag[..end];
|
rest = after.trim_start();
|
||||||
let name: String = inner
|
}
|
||||||
.trim_start_matches('/')
|
true
|
||||||
.chars()
|
}
|
||||||
.take_while(char::is_ascii_alphanumeric)
|
|
||||||
.collect();
|
fn canonical_row(row: &str) -> Option<&str> {
|
||||||
!inner.contains('<') && matches!(name.as_str(), "tr" | "td" | "span")
|
let mut rest = row.strip_prefix("<tr>")?;
|
||||||
})
|
for _ in 0..9 {
|
||||||
|
rest = canonical_cell(rest.trim_start())?;
|
||||||
|
}
|
||||||
|
rest.trim_start().strip_prefix("</tr>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_cell(cell: &str) -> Option<&str> {
|
||||||
|
let rest = open_tag(cell, "td")?.trim_start();
|
||||||
|
let rest = if rest.starts_with("<span") {
|
||||||
|
plain_text(open_tag(rest, "span")?)?
|
||||||
|
.strip_prefix("</span>")?
|
||||||
|
.trim_start()
|
||||||
|
} else {
|
||||||
|
plain_text(rest)?
|
||||||
|
};
|
||||||
|
rest.strip_prefix("</td>")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rest after `<name attrs>`.
|
||||||
|
fn open_tag<'a>(markup: &'a str, name: &str) -> Option<&'a str> {
|
||||||
|
let mut rest = markup.strip_prefix('<')?.strip_prefix(name)?;
|
||||||
|
loop {
|
||||||
|
if let Some(after) = rest.strip_prefix('>') {
|
||||||
|
return Some(after);
|
||||||
|
}
|
||||||
|
rest = rest.strip_prefix(' ')?;
|
||||||
|
let name_len = rest
|
||||||
|
.bytes()
|
||||||
|
.take_while(|b| b.is_ascii_lowercase() || *b == b'-')
|
||||||
|
.count();
|
||||||
|
if name_len == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
rest = rest[name_len..].strip_prefix("=\"")?;
|
||||||
|
let end = rest.find(['"', '<', '>'])?;
|
||||||
|
rest = rest[end..].strip_prefix('"')?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rest after text without markup or character references.
|
||||||
|
fn plain_text(text: &str) -> Option<&str> {
|
||||||
|
let end = text.find('<').unwrap_or(text.len());
|
||||||
|
let mut refs = text[..end].match_indices('&');
|
||||||
|
refs.all(|(at, _)| text[at..].starts_with(" "))
|
||||||
|
.then_some(&text[end..])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `MM/DD/YYYY` value of the input with this id.
|
/// The `MM/DD/YYYY` value of the input with this id.
|
||||||
|
|
@ -180,7 +235,7 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
|
||||||
});
|
});
|
||||||
let single = lower.matches("<tbody").count() == 1 && lower.matches("</tbody").count() == 1;
|
let single = lower.matches("<tbody").count() == 1 && lower.matches("</tbody").count() == 1;
|
||||||
let Some((body_start, body_end)) =
|
let Some((body_start, body_end)) =
|
||||||
body.filter(|(start, end)| single && plain_markup(&lower[*start..*end]))
|
body.filter(|(start, end)| single && canonical_rows(&lower[*start..*end]))
|
||||||
else {
|
else {
|
||||||
return Err(IdxError::ParseError(
|
return Err(IdxError::ParseError(
|
||||||
"ipot broker summary: no single plain table in the page".to_string(),
|
"ipot broker summary: no single plain table in the page".to_string(),
|
||||||
|
|
@ -198,9 +253,8 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
|
||||||
value
|
value
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
// A skipped or merged row would shift brokers between ranks, so the body
|
// The body is whole rows (see `canonical_rows`); ranks must run in
|
||||||
// must be whole rows only, each one `<tr>` of nine closed cells, ranked
|
// order, and a broker after a side's blank 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;
|
||||||
let mut at = body_start;
|
let mut at = body_start;
|
||||||
|
|
@ -212,17 +266,11 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
|
||||||
let end = rest
|
let end = rest
|
||||||
.find("</tr>")
|
.find("</tr>")
|
||||||
.map_or(rest.len(), |end| end + "</tr>".len());
|
.map_or(rest.len(), |end| end + "</tr>".len());
|
||||||
let piece = &rest[..end];
|
|
||||||
let original = &html[at..at + end];
|
let original = &html[at..at + end];
|
||||||
at += end;
|
at += end;
|
||||||
rank += 1;
|
rank += 1;
|
||||||
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);
|
let row = cells(original);
|
||||||
if !whole_row || row.len() != 9 || row[4].text.parse::<u32>().ok() != Some(rank) {
|
if 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(),
|
||||||
|
|
@ -421,6 +469,18 @@ mod tests {
|
||||||
};
|
};
|
||||||
let commented = hidden(&|cells| format!("<tr><!--{cells}--></tr>"));
|
let commented = hidden(&|cells| format!("<tr><!--{cells}--></tr>"));
|
||||||
let in_attribute = hidden(&|cells| format!("<tr data-x='{cells}'></tr>"));
|
let in_attribute = hidden(&|cells| format!("<tr data-x='{cells}'></tr>"));
|
||||||
|
let after_quoted_end = hidden(&|cells| format!("<tr data-x='>{cells}'></tr>"));
|
||||||
|
// Closing tags with a space, a cell under another tag name, and a
|
||||||
|
// character reference a browser would decode.
|
||||||
|
let lots = "<span class=\"text-up\">828,163 </span>";
|
||||||
|
let spaced_row_end = DAY.replacen(lots, &format!("</tr >{lots}"), 1);
|
||||||
|
let spaced_cell_end = DAY.replacen(lots, &format!("</td >{lots}"), 1);
|
||||||
|
let other_tag = DAY.replacen(
|
||||||
|
"<td align=\"center\">5</td>",
|
||||||
|
"<td-x align=\"center\">5</td>",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let reference = DAY.replacen(">828,163 <", ">828,163 <", 1);
|
||||||
for page in [
|
for page in [
|
||||||
short_row,
|
short_row,
|
||||||
skipped_rank,
|
skipped_rank,
|
||||||
|
|
@ -431,6 +491,11 @@ mod tests {
|
||||||
early_end,
|
early_end,
|
||||||
commented,
|
commented,
|
||||||
in_attribute,
|
in_attribute,
|
||||||
|
after_quoted_end,
|
||||||
|
spaced_row_end,
|
||||||
|
spaced_cell_end,
|
||||||
|
other_tag,
|
||||||
|
reference,
|
||||||
"</tbody><tbody>".to_string(),
|
"</tbody><tbody>".to_string(),
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue