mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
fix(core): read IPOT's four footer spans exactly; bound the page size
The footer div must hold exactly IPOT's four text-only spans, in order and each starting with its label, with nothing else shown between them, so a total split from or across spans no longer reads as a smaller figure. Pages over 64 KiB or 2,000 tags (IPOT's is about 8 KB and under 500) are rejected before parsing, which bounds tree building on deep nesting (Codex re-check, round 9). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
parent
63c4d9342f
commit
ee4fe4b852
1 changed files with 55 additions and 28 deletions
|
|
@ -296,24 +296,42 @@ fn sole_child<'a>(parent: ElementRef<'a>, name: &str) -> Option<ElementRef<'a>>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The footer's texts, e.g. `T. Val : 6.6 T`, from `tfoot > tr > th > div >
|
const MAX_PAGE_BYTES: usize = 64 * 1024;
|
||||||
/// span` as IPOT writes it, each span holding text only.
|
const MAX_PAGE_TAGS: usize = 2_000;
|
||||||
fn footer_texts(tfoot: ElementRef) -> Option<Vec<String>> {
|
|
||||||
|
/// The footer's labels, in the order IPOT writes them.
|
||||||
|
const FOOTER: [&str; 4] = ["T. Val :", "F. NVal :", "T.Lot :", "Avg :"];
|
||||||
|
|
||||||
|
/// The footer's figures after their labels (`6.6 T` for `T. Val : 6.6 T`),
|
||||||
|
/// from `tfoot > tr > th > div` holding exactly IPOT's four text-only spans
|
||||||
|
/// in order, with nothing else shown between them.
|
||||||
|
fn footer_figures(tfoot: ElementRef) -> Option<[String; 4]> {
|
||||||
let div = sole_child(sole_child(sole_child(tfoot, "tr")?, "th")?, "div")?;
|
let div = sole_child(sole_child(sole_child(tfoot, "tr")?, "th")?, "div")?;
|
||||||
div.child_elements()
|
let mut figures = Vec::new();
|
||||||
.map(|span| {
|
for child in div.children() {
|
||||||
(span.value().name() == "span")
|
match child.value() {
|
||||||
.then(|| leaf_text(span))
|
Node::Text(text) if text.trim().is_empty() => {}
|
||||||
.flatten()
|
Node::Comment(_) => {}
|
||||||
})
|
Node::Element(element) if element.name() == "span" => {
|
||||||
.map(|text| text.map(|text| clean(&text)))
|
let text = clean(&leaf_text(ElementRef::wrap(child)?)?);
|
||||||
.collect()
|
let label = FOOTER.get(figures.len())?;
|
||||||
|
figures.push(text.strip_prefix(label)?.trim().to_string());
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
figures.try_into().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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),
|
||||||
/// so markup it would not show as table cells or footer text, such as
|
/// so markup it would not show as table cells or footer text, such as
|
||||||
/// comments, attributes or other elements' content, is never read as such.
|
/// comments, attributes or other elements' content, is never read as such.
|
||||||
pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
|
pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
|
||||||
|
// IPOT's page is about 8 KB with under 500 tags. A bound before parsing
|
||||||
|
// keeps HTML tree building (which scans open elements per tag) cheap.
|
||||||
|
if html.len() > MAX_PAGE_BYTES || html.matches('<').count() > MAX_PAGE_TAGS {
|
||||||
|
return Err(parse_error("page larger than IPOT serves"));
|
||||||
|
}
|
||||||
let page = Html::parse_fragment(html);
|
let page = Html::parse_fragment(html);
|
||||||
let shown: String = page.root_element().text().collect();
|
let shown: String = page.root_element().text().collect();
|
||||||
if shown.to_ascii_lowercase().contains("invalid stock code") {
|
if shown.to_ascii_lowercase().contains("invalid stock code") {
|
||||||
|
|
@ -389,23 +407,13 @@ pub fn parse_broker_summary(html: &str) -> Result<IpotPage, IdxError> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let footer = footer_texts(tfoot).ok_or_else(|| parse_error("unexpected footer markup"))?;
|
let [value, net, lots, average] =
|
||||||
for label in ["T. Val :", "F. NVal :", "T.Lot :", "Avg :"] {
|
footer_figures(tfoot).ok_or_else(|| parse_error("unexpected footer markup"))?;
|
||||||
if footer.iter().filter(|text| text.starts_with(label)).count() > 1 {
|
raw.total_value = figure(&value);
|
||||||
return Err(parse_error(&format!("{label} twice in the footer")));
|
raw.foreign_net_value = figure(&net);
|
||||||
}
|
raw.total_lots = figure(&lots);
|
||||||
}
|
|
||||||
let mut total = |label: &str| {
|
|
||||||
footer
|
|
||||||
.iter()
|
|
||||||
.find_map(|text| text.strip_prefix(label))
|
|
||||||
.and_then(&mut figure)
|
|
||||||
};
|
|
||||||
raw.total_value = total("T. Val :");
|
|
||||||
raw.foreign_net_value = total("F. NVal :");
|
|
||||||
raw.total_lots = total("T.Lot :");
|
|
||||||
// The page prints `Avg : 0` when nothing traded; there is no average then.
|
// The page prints `Avg : 0` when nothing traded; there is no average then.
|
||||||
raw.average_price = total("Avg :").filter(|_| raw.total_lots.is_some_and(|lots| lots > 0));
|
raw.average_price = figure(&average).filter(|_| raw.total_lots.is_some_and(|lots| lots > 0));
|
||||||
raw.rounded = rounded;
|
raw.rounded = rounded;
|
||||||
Ok(IpotPage::Summary(raw))
|
Ok(IpotPage::Summary(raw))
|
||||||
}
|
}
|
||||||
|
|
@ -809,7 +817,26 @@ mod tests {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
for (what, page) in hidden {
|
let total = "<span class=\"fsize12 ml5 mr5\">T. Val : 6.6 T</span>";
|
||||||
|
let more = [
|
||||||
|
(
|
||||||
|
"a total split from its span",
|
||||||
|
DAY.replacen(
|
||||||
|
total,
|
||||||
|
"<span class=\"fsize12 ml5 mr5\">T. Val : 6</span>.6 T",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a total split over two spans",
|
||||||
|
DAY.replacen(total, "<span>T. Val : 6</span><span>00</span>", 1),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"deeply nested divs",
|
||||||
|
"<div>".repeat(4_000) + &"</div>".repeat(4_000) + DAY,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (what, page) in hidden.into_iter().chain(more) {
|
||||||
assert!(read(&page).is_none(), "{what}");
|
assert!(read(&page).is_none(), "{what}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue