fix(core): bind IPOT class tokens to the elements that carry them

Classes are allowed per element type, not page-wide, and the table and
each footer element on the way to a total must carry exactly IPOT's
classes, so Bootstrap's visible-xs (hidden at desktop widths) on a total
or the table rejects the page (Pullfrog review).

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 14:35:02 +00:00
commit 7d55be80c5

View file

@ -207,43 +207,53 @@ const ELEMENTS: [(&str, &[&str]); 15] = [
("th", &["align", "colspan"]), ("th", &["align", "colspan"]),
("td", &["align"]), ("td", &["align"]),
]; ];
const CLASSES: [&str; 36] = [ /// The class tokens each element type carries in IPOT's page.
"bdr-t", const CLASSES: [(&str, &[&str]); 7] = [
"btn", (
"btn-block", "button",
"btn-default", &["btn", "btn-block", "btn-default", "btn-xs-input"],
"btn-xs-input", ),
"col", (
"col-sm-1", "div",
"col-sm-2", &[
"col-sm-3", "col",
"col-xs-2", "col-sm-1",
"col-xs-5", "col-sm-2",
"form-control", "col-sm-3",
"fsize12", "col-xs-2",
"input-xs", "col-xs-5",
"mincol-3", "mincol-3",
"ml5", "mt15",
"mr5", "pa5",
"mt15", "pt20",
"nm", "row",
"noborder", "table-responsive",
"pa5", "text-center",
"pt20", "valign-middle",
"row", "visible-xs",
"table", ],
"table-hover", ),
"table-responsive", ("input", &["form-control", "input-xs", "text-center"]),
"table-summary", ("select", &["form-control", "input-xs"]),
"text-", (
"text-bumn", "span",
"text-center", &[
"text-down", "fsize12",
"text-foreign", "ml5",
"text-local", "mr5",
"text-up", "text-",
"valign-middle", "text-bumn",
"visible-xs", "text-down",
"text-foreign",
"text-local",
"text-up",
],
),
(
"table",
&["nm", "noborder", "table", "table-hover", "table-summary"],
),
("tr", &["bdr-t"]),
]; ];
const PINNED: [(&str, &str, &str); 4] = [ const PINNED: [(&str, &str, &str); 4] = [
("input", "type", "text"), ("input", "type", "text"),
@ -265,7 +275,12 @@ fn known_markup(page: &Html) -> bool {
.iter() .iter()
.filter(|(on, pinned, _)| *on == name && *pinned == attr) .filter(|(on, pinned, _)| *on == name && *pinned == attr)
.all(|(_, _, only)| *only == value) .all(|(_, _, only)| *only == value)
&& (attr != "class" || class_tokens(value).all(|class| CLASSES.contains(&class))) && (attr != "class"
|| class_tokens(value).all(|class| {
CLASSES
.iter()
.any(|(on, classes)| *on == name && classes.contains(&class))
}))
}) })
}) })
} }
@ -275,17 +290,24 @@ fn known_markup(page: &Html) -> bool {
fn placed(element: ElementRef, path: &[(&str, &str)]) -> bool { fn placed(element: ElementRef, path: &[(&str, &str)]) -> bool {
let ancestors: Vec<ElementRef> = element.ancestors().filter_map(ElementRef::wrap).collect(); let ancestors: Vec<ElementRef> = element.ancestors().filter_map(ElementRef::wrap).collect();
ancestors.len() == path.len() + 1 ancestors.len() == path.len() + 1
&& ancestors.iter().zip(path).all(|(ancestor, (name, class))| { && ancestors
let mut found: Vec<&str> = .iter()
class_tokens(ancestor.attr("class").unwrap_or_default()).collect(); .zip(path)
let mut wanted: Vec<&str> = class_tokens(class).collect(); .all(|(ancestor, (name, classes))| {
// As sets: order, spacing and repeats don't change the classes. ancestor.value().name() == *name && same_classes(*ancestor, classes)
found.sort_unstable(); })
found.dedup(); }
wanted.sort_unstable();
wanted.dedup(); /// Whether `element`'s classes are exactly `classes`, as sets: order,
ancestor.value().name() == *name && found == wanted /// spacing and repeats don't change them.
}) fn same_classes(element: ElementRef, classes: &str) -> bool {
let set = |value: &str| {
let mut tokens: Vec<String> = class_tokens(value).map(str::to_string).collect();
tokens.sort_unstable();
tokens.dedup();
tokens
};
set(element.attr("class").unwrap_or_default()) == set(classes)
} }
const TABLE_PATH: [(&str, &str); 1] = [("div", "table-responsive")]; const TABLE_PATH: [(&str, &str); 1] = [("div", "table-responsive")];
@ -356,14 +378,20 @@ const FOOTER: [&str; 4] = ["T. Val :", "F. NVal :", "T.Lot :", "Avg :"];
/// from `tfoot > tr > th > div` holding exactly IPOT's four text-only spans /// from `tfoot > tr > th > div` holding exactly IPOT's four text-only spans
/// in order, with nothing else shown between them. /// in order, with nothing else shown between them.
fn footer_figures(tfoot: ElementRef) -> Option<[String; 4]> { fn footer_figures(tfoot: ElementRef) -> Option<[String; 4]> {
let div = sole_child(sole_child(sole_child(tfoot, "tr")?, "th")?, "div")?; // Each element on the way carries exactly IPOT's classes, which could
// otherwise hide a figure (Bootstrap's `visible-xs` on a total, say).
let row = sole_child(tfoot, "tr").filter(|row| same_classes(*row, "bdr-t"))?;
let div = sole_child(sole_child(row, "th")?, "div")
.filter(|div| same_classes(*div, "text-center pt20 valign-middle"))?;
let mut figures = Vec::new(); let mut figures = Vec::new();
for child in div.children() { for child in div.children() {
match child.value() { match child.value() {
Node::Text(text) if text.trim().is_empty() => {} Node::Text(text) if text.trim().is_empty() => {}
Node::Comment(_) => {} Node::Comment(_) => {}
Node::Element(element) if element.name() == "span" => { Node::Element(element) if element.name() == "span" => {
let text = clean(&leaf_text(ElementRef::wrap(child)?)?); let span = ElementRef::wrap(child)
.filter(|span| same_classes(*span, "fsize12 ml5 mr5"))?;
let text = clean(&leaf_text(span)?);
let label = FOOTER.get(figures.len())?; let label = FOOTER.get(figures.len())?;
figures.push(text.strip_prefix(label)?.trim().to_string()); figures.push(text.strip_prefix(label)?.trim().to_string());
} }
@ -391,7 +419,10 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
return Err(parse_error("markup the page does not use")); return Err(parse_error("markup the page does not use"));
} }
let table = only(page.root_element(), "table") let table = only(page.root_element(), "table")
.filter(|table| placed(*table, &TABLE_PATH)) .filter(|table| {
placed(*table, &TABLE_PATH)
&& same_classes(*table, "table table-summary table-hover noborder nm")
})
.ok_or_else(|| parse_error("no single table where the page puts it"))?; .ok_or_else(|| parse_error("no single table where the page puts it"))?;
let parts: Vec<ElementRef> = table.child_elements().collect(); let parts: Vec<ElementRef> = table.child_elements().collect();
let [head, body, tfoot] = parts[..] else { let [head, body, tfoot] = parts[..] else {
@ -986,6 +1017,20 @@ mod tests {
read(&blank_code_with_figures).is_none(), read(&blank_code_with_figures).is_none(),
"figures beside a blank broker code" "figures beside a blank broker code"
); );
// Classes bound to their places: Bootstrap's `visible-xs` hides at
// desktop widths, so on a total or the table it rejects the page.
let hidden_total = DAY.replacen(
"<span class=\"fsize12 ml5 mr5\">T. Val",
"<span class=\"fsize12 ml5 mr5 visible-xs\">T. Val",
1,
);
assert!(read(&hidden_total).is_none(), "visible-xs on a total");
let hidden_table = DAY.replacen(
"class=\"table table-summary",
"class=\"visible-xs table table-summary",
1,
);
assert!(read(&hidden_table).is_none(), "visible-xs on the table");
let script_text = DAY.replacen("></script>", ">/* invalid stock code */</script>", 1); let script_text = DAY.replacen("></script>", ">/* invalid stock code */</script>", 1);
assert_eq!( assert_eq!(
read(&script_text), read(&script_text),