mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
fix(core): accept only IPOT's own markup, in its own places
Replace the list of hiding mechanisms with what the page is: every element must be one IPOT uses, with only its attributes and class tokens, and with input type, the footer div's style, colspan and button type pinned; the table and date inputs must sit at IPOT's exact div paths; the table is thead, tbody, tfoot; totals come from tfoot > tr > th > div > span only; and a cell's span holds all the cell shows. A popover, an escaped inline style, a hidden input, a total in details or a code beside its span now reject the page, and the footer is walked directly (linear). Tests now state the property: a page is rejected or read as shown, never as other data (Codex re-check, round 8). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
parent
723da4a5a6
commit
63c4d9342f
1 changed files with 227 additions and 67 deletions
|
|
@ -141,6 +141,7 @@ fn cell(td: ElementRef) -> Option<(String, Option<String>)> {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
|
let mut span_text = None;
|
||||||
let mut class = None;
|
let mut class = None;
|
||||||
for child in td.children() {
|
for child in td.children() {
|
||||||
match child.value() {
|
match child.value() {
|
||||||
|
|
@ -154,47 +155,159 @@ fn cell(td: ElementRef) -> Option<(String, Option<String>)> {
|
||||||
if !only_attrs(span, &["class"]) {
|
if !only_attrs(span, &["class"]) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
text.push_str(&leaf_text(span)?);
|
span_text = Some(leaf_text(span)?);
|
||||||
class = Some(value.to_string());
|
class = Some(value.to_string());
|
||||||
}
|
}
|
||||||
_ => return None,
|
_ => return None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some((clean(&text), class))
|
match span_text {
|
||||||
|
// The span holds all the cell shows, so its class is the code's.
|
||||||
|
Some(inner) if clean(&text).is_empty() => Some((clean(&inner), class)),
|
||||||
|
Some(_) => None,
|
||||||
|
None => Some((clean(&text), None)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `element` sits in plain divs only, as IPOT's table and date
|
/// Each element IPOT's page uses with the attributes it gives it, the class
|
||||||
/// inputs do; inside anything else (details, dialog, noscript, object,
|
/// tokens it uses, and the attribute values that change what shows (recorded
|
||||||
/// select, ...) it may not show.
|
/// 2026-10). Markup outside this list, whatever it would do (hide, move or
|
||||||
fn in_divs(element: ElementRef) -> bool {
|
/// replace content: `hidden`, `popover`, `template`, other styles ...),
|
||||||
element
|
/// rejects the page. Rules in the site's stylesheet cannot be seen here.
|
||||||
.ancestors()
|
const ELEMENTS: [(&str, &[&str]); 15] = [
|
||||||
.filter_map(ElementRef::wrap)
|
("html", &[]),
|
||||||
.all(|ancestor| matches!(ancestor.value().name(), "div" | "html"))
|
("button", &["class", "onclick", "type"]),
|
||||||
|
("div", &["class", "id", "style"]),
|
||||||
|
(
|
||||||
|
"input",
|
||||||
|
&["class", "id", "name", "placeholder", "type", "value"],
|
||||||
|
),
|
||||||
|
("option", &["selected", "value"]),
|
||||||
|
("script", &["src"]),
|
||||||
|
("select", &["class", "id"]),
|
||||||
|
("span", &["class"]),
|
||||||
|
(
|
||||||
|
"table",
|
||||||
|
&["border", "cellpadding", "cellspacing", "class", "width"],
|
||||||
|
),
|
||||||
|
("thead", &[]),
|
||||||
|
("tbody", &[]),
|
||||||
|
("tfoot", &[]),
|
||||||
|
("tr", &["class"]),
|
||||||
|
("th", &["align", "colspan"]),
|
||||||
|
("td", &["align"]),
|
||||||
|
];
|
||||||
|
const CLASSES: [&str; 36] = [
|
||||||
|
"bdr-t",
|
||||||
|
"btn",
|
||||||
|
"btn-block",
|
||||||
|
"btn-default",
|
||||||
|
"btn-xs-input",
|
||||||
|
"col",
|
||||||
|
"col-sm-1",
|
||||||
|
"col-sm-2",
|
||||||
|
"col-sm-3",
|
||||||
|
"col-xs-2",
|
||||||
|
"col-xs-5",
|
||||||
|
"form-control",
|
||||||
|
"fsize12",
|
||||||
|
"input-xs",
|
||||||
|
"mincol-3",
|
||||||
|
"ml5",
|
||||||
|
"mr5",
|
||||||
|
"mt15",
|
||||||
|
"nm",
|
||||||
|
"noborder",
|
||||||
|
"pa5",
|
||||||
|
"pt20",
|
||||||
|
"row",
|
||||||
|
"table",
|
||||||
|
"table-hover",
|
||||||
|
"table-responsive",
|
||||||
|
"table-summary",
|
||||||
|
"text-",
|
||||||
|
"text-bumn",
|
||||||
|
"text-center",
|
||||||
|
"text-down",
|
||||||
|
"text-foreign",
|
||||||
|
"text-local",
|
||||||
|
"text-up",
|
||||||
|
"valign-middle",
|
||||||
|
"visible-xs",
|
||||||
|
];
|
||||||
|
const PINNED: [(&str, &str, &str); 4] = [
|
||||||
|
("input", "type", "text"),
|
||||||
|
("button", "type", "button"),
|
||||||
|
("div", "style", "height:50px;"),
|
||||||
|
("th", "colspan", "9"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Whether every element and attribute in the page is one IPOT uses.
|
||||||
|
fn known_markup(page: &Html) -> bool {
|
||||||
|
page.root_element().descendent_elements().all(|element| {
|
||||||
|
let name = element.value().name();
|
||||||
|
let Some((_, attrs)) = ELEMENTS.iter().find(|(known, _)| *known == name) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
element.value().attrs().all(|(attr, value)| {
|
||||||
|
attrs.contains(&attr)
|
||||||
|
&& PINNED
|
||||||
|
.iter()
|
||||||
|
.filter(|(on, pinned, _)| *on == name && *pinned == attr)
|
||||||
|
.all(|(_, _, only)| *only == value)
|
||||||
|
&& (attr != "class"
|
||||||
|
|| value
|
||||||
|
.split_whitespace()
|
||||||
|
.all(|class| CLASSES.contains(&class)))
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `MM/DD/YYYY` value of the one input with this id, in plain divs.
|
/// Whether `element`'s ancestors are exactly `path` (name and class, nearest
|
||||||
|
/// first), then the page root: where IPOT places its table and date inputs.
|
||||||
|
fn placed(element: ElementRef, path: &[(&str, &str)]) -> bool {
|
||||||
|
let ancestors: Vec<ElementRef> = element.ancestors().filter_map(ElementRef::wrap).collect();
|
||||||
|
ancestors.len() == path.len() + 1
|
||||||
|
&& ancestors.iter().zip(path).all(|(ancestor, (name, class))| {
|
||||||
|
ancestor.value().name() == *name && ancestor.attr("class") == Some(*class)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLE_PATH: [(&str, &str); 1] = [("div", "table-responsive")];
|
||||||
|
const INPUT_PATH: [(&str, &str); 3] = [
|
||||||
|
("div", "col col-xs-5 col-sm-3"),
|
||||||
|
("div", "row mincol-3"),
|
||||||
|
("div", "pa5"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The `MM/DD/YYYY` value of the one input with this id, where IPOT puts it.
|
||||||
fn input_date(page: &Html, id: &str) -> Option<NaiveDate> {
|
fn input_date(page: &Html, id: &str) -> Option<NaiveDate> {
|
||||||
let input =
|
let input = only(page.root_element(), &format!("input#{id}"))
|
||||||
only(page.root_element(), &format!("input#{id}")).filter(|input| in_divs(*input))?;
|
.filter(|input| placed(*input, &INPUT_PATH))?;
|
||||||
NaiveDate::parse_from_str(input.attr("value")?.trim(), "%m/%d/%Y").ok()
|
NaiveDate::parse_from_str(input.attr("value")?.trim(), "%m/%d/%Y").ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether anything in the page is hidden by markup alone (`hidden`, an
|
/// The only child element of `parent`, when it is a `name`.
|
||||||
/// inline `display`/`visibility` style) or inert (`template`). Styling from
|
fn sole_child<'a>(parent: ElementRef<'a>, name: &str) -> Option<ElementRef<'a>> {
|
||||||
/// the site's own stylesheet cannot be seen here.
|
let mut children = parent.child_elements();
|
||||||
fn hides_content(page: &Html) -> bool {
|
match (children.next(), children.next()) {
|
||||||
page.root_element().descendent_elements().any(|element| {
|
(Some(child), None) if child.value().name() == name => Some(child),
|
||||||
let style = element
|
_ => None,
|
||||||
.attr("style")
|
}
|
||||||
.unwrap_or_default()
|
}
|
||||||
.to_ascii_lowercase()
|
|
||||||
.replace(' ', "");
|
/// The footer's texts, e.g. `T. Val : 6.6 T`, from `tfoot > tr > th > div >
|
||||||
element.value().name() == "template"
|
/// span` as IPOT writes it, each span holding text only.
|
||||||
|| element.attr("hidden").is_some()
|
fn footer_texts(tfoot: ElementRef) -> Option<Vec<String>> {
|
||||||
|| style.contains("display:none")
|
let div = sole_child(sole_child(sole_child(tfoot, "tr")?, "th")?, "div")?;
|
||||||
|| style.contains("visibility:hidden")
|
div.child_elements()
|
||||||
})
|
.map(|span| {
|
||||||
|
(span.value().name() == "span")
|
||||||
|
.then(|| leaf_text(span))
|
||||||
|
.flatten()
|
||||||
|
})
|
||||||
|
.map(|text| text.map(|text| clean(&text)))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The page is read as a browser builds it from the fragment (html5ever),
|
/// The page is read as a browser builds it from the fragment (html5ever),
|
||||||
|
|
@ -206,15 +319,20 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
|
||||||
if shown.to_ascii_lowercase().contains("invalid stock code") {
|
if shown.to_ascii_lowercase().contains("invalid stock code") {
|
||||||
return Ok(IpotPage::Invalid);
|
return Ok(IpotPage::Invalid);
|
||||||
}
|
}
|
||||||
if hides_content(&page) {
|
if !known_markup(&page) {
|
||||||
return Err(parse_error("hidden or inert markup in the page"));
|
return Err(parse_error("markup the page does not use"));
|
||||||
}
|
}
|
||||||
let table = only(page.root_element(), "table")
|
let table = only(page.root_element(), "table")
|
||||||
.ok_or_else(|| parse_error("no single table in the page"))?;
|
.filter(|table| placed(*table, &TABLE_PATH))
|
||||||
if !in_divs(table) {
|
.ok_or_else(|| parse_error("no single table where the page puts it"))?;
|
||||||
return Err(parse_error("the table is not in plain page markup"));
|
let parts: Vec<ElementRef> = table.child_elements().collect();
|
||||||
|
let [_, body, tfoot] = parts[..] else {
|
||||||
|
return Err(parse_error("unexpected table parts"));
|
||||||
|
};
|
||||||
|
let names: Vec<&str> = parts.iter().map(|part| part.value().name()).collect();
|
||||||
|
if names != ["thead", "tbody", "tfoot"] {
|
||||||
|
return Err(parse_error("unexpected table parts"));
|
||||||
}
|
}
|
||||||
let body = only(table, "tbody").ok_or_else(|| parse_error("no single table body"))?;
|
|
||||||
let mut raw = BrokerSummaryRaw {
|
let mut raw = BrokerSummaryRaw {
|
||||||
from: input_date(&page, "startDateBrokerSummary"),
|
from: input_date(&page, "startDateBrokerSummary"),
|
||||||
to: input_date(&page, "endDateBrokerSummary"),
|
to: input_date(&page, "endDateBrokerSummary"),
|
||||||
|
|
@ -271,15 +389,7 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The footer's figures, e.g. `T. Val : 6.6 T`, each in its own span
|
let footer = footer_texts(tfoot).ok_or_else(|| parse_error("unexpected footer markup"))?;
|
||||||
// holding text only, each label once.
|
|
||||||
let mut footer = Vec::new();
|
|
||||||
for span in table.select(&selector("tfoot span")) {
|
|
||||||
let text = leaf_text(span)
|
|
||||||
.filter(|_| only_attrs(span, &["class"]))
|
|
||||||
.ok_or_else(|| parse_error("unexpected footer markup"))?;
|
|
||||||
footer.push(clean(&text));
|
|
||||||
}
|
|
||||||
for label in ["T. Val :", "F. NVal :", "T.Lot :", "Avg :"] {
|
for label in ["T. Val :", "F. NVal :", "T.Lot :", "Avg :"] {
|
||||||
if footer.iter().filter(|text| text.starts_with(label)).count() > 1 {
|
if footer.iter().filter(|text| text.starts_with(label)).count() > 1 {
|
||||||
return Err(parse_error(&format!("{label} twice in the footer")));
|
return Err(parse_error(&format!("{label} twice in the footer")));
|
||||||
|
|
@ -624,33 +734,83 @@ mod tests {
|
||||||
DAY.replacen("</tbody>", "</tbody><script></script>", 1),
|
DAY.replacen("</tbody>", "</tbody><script></script>", 1),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
// Each page is rejected or read as shown; never read as other data.
|
||||||
|
let read = |page: &str| match parse_broker_summary(page) {
|
||||||
|
Ok(IpotPage::Summary(raw)) => Some(raw),
|
||||||
|
Err(IdxError::ParseError(_)) => None,
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
};
|
||||||
for (what, page) in same {
|
for (what, page) in same {
|
||||||
assert_eq!(summary(&page), original, "{what}");
|
if let Some(raw) = read(&page) {
|
||||||
|
assert_eq!(raw, original, "{what}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Markup a browser shows differently: the figure it shows, not ours.
|
// Markup a browser shows differently: the figure it shows, not ours.
|
||||||
let angle = summary(&DAY.replacen(">828,163 <", ">8>28,163 <", 1));
|
|
||||||
assert_eq!(angle.buyers[4].lots, None);
|
|
||||||
let lots = "<span class=\"text-up\">828,163 </span>";
|
let lots = "<span class=\"text-up\">828,163 </span>";
|
||||||
let early_cell_end = summary(&DAY.replacen(lots, &format!("</td >{lots}"), 1));
|
let shown_otherwise = [
|
||||||
assert_eq!(early_cell_end.buyers[4].lots, None);
|
(
|
||||||
let commented_date =
|
"a '>' in a figure",
|
||||||
summary(&DAY.replacen("value=\"10/16/2026\"", "value=\"10/<!--x-->16/2026\"", 1));
|
DAY.replacen(">828,163 <", ">8>28,163 <", 1),
|
||||||
assert_eq!(commented_date.from, None);
|
),
|
||||||
let second_start = DAY.replacen(
|
(
|
||||||
"</tbody>",
|
"a cell ended early",
|
||||||
"</tbody><input id=\"startDateBrokerSummary\" value=\"10/15/2026\">",
|
DAY.replacen(lots, &format!("</td >{lots}"), 1),
|
||||||
1,
|
),
|
||||||
);
|
];
|
||||||
assert_eq!(
|
for (what, page) in shown_otherwise {
|
||||||
summary(&second_start).from,
|
if let Some(raw) = read(&page) {
|
||||||
None,
|
assert_eq!(raw.buyers[4].lots, None, "{what}");
|
||||||
"an id twice is no echoed date"
|
}
|
||||||
);
|
}
|
||||||
let closed = DAY.replacen("<input", "<details><input", 1);
|
let dates_shown_otherwise = [
|
||||||
assert_eq!(
|
(
|
||||||
summary(&closed).from,
|
"a comment in the date",
|
||||||
None,
|
DAY.replacen("value=\"10/16/2026\"", "value=\"10/<!--x-->16/2026\"", 1),
|
||||||
"a date in closed details is not shown"
|
),
|
||||||
);
|
(
|
||||||
|
"the date input twice",
|
||||||
|
DAY.replacen(
|
||||||
|
"</tbody>",
|
||||||
|
"</tbody><input id=\"startDateBrokerSummary\" value=\"10/15/2026\">",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"the date input in details",
|
||||||
|
DAY.replacen("<input", "<details><input", 1),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a hidden date input",
|
||||||
|
DAY.replacen("type=\"text\"", "type=\"hidden\"", 1),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (what, page) in dates_shown_otherwise {
|
||||||
|
if let Some(raw) = read(&page) {
|
||||||
|
assert_eq!(raw.from, None, "{what}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ways to hide or relabel what IPOT shows, all outside its markup.
|
||||||
|
let hidden = [
|
||||||
|
("a popover", format!("<div popover>{DAY}</div>")),
|
||||||
|
(
|
||||||
|
"an inline style",
|
||||||
|
DAY.replacen("<table ", "<table style=\"display:	none\" ", 1),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a total in details",
|
||||||
|
DAY.replacen("T. Val : 6.6 T", "<details>T. Val : 6.6 T</details>", 1),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a code beside its span",
|
||||||
|
DAY.replacen(
|
||||||
|
"<span class=\"text-local\">MG</span>",
|
||||||
|
"M<span class=\"text-foreign\"></span>G",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (what, page) in hidden {
|
||||||
|
assert!(read(&page).is_none(), "{what}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue