From 79b15d51b94a3f2966e697048a0fd3824bec49b0 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 12:54:34 +0000 Subject: [PATCH] fix(core): parse IPOT pages with html5ever Six rounds of the 0.4.0 review found crafted markup that the hand-written reader read differently from HTML (cells in comments or attributes, stray or spaced tags, a decoy date attribute or total, foreign content). Parse the page with scraper (html5ever) instead: dates are the inputs' values, rows are the single tbody's tr children with nine td each, totals are the tfoot spans, all as a browser builds them. Rows that don't line up still reject the page. scraper's CSS crates (selectors, cssparser) are MPL-2.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- Cargo.lock | 266 ++++++++++++ crates/idx-core/Cargo.toml | 1 + crates/idx-core/src/api/ipot/parse.rs | 564 +++++++++++--------------- docs/ARCHITECTURE.md | 7 +- 4 files changed, 517 insertions(+), 321 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 306781f..070ab65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,6 +380,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + [[package]] name = "csv" version = "1.4.0" @@ -444,6 +467,27 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "difflib" version = "0.4.0" @@ -501,12 +545,33 @@ dependencies = [ "litrs", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ego-tree" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -732,6 +797,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "http" version = "1.4.0" @@ -910,6 +985,7 @@ dependencies = [ "fastrand", "quick-xml", "rusqlite", + "scraper", "serde", "serde_json", "sha2", @@ -1025,6 +1101,17 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1047,6 +1134,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.31.3" @@ -1149,6 +1242,59 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1176,6 +1322,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "predicates" version = "3.1.4" @@ -1377,6 +1529,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1469,6 +1636,45 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" +dependencies = [ + "cssparser", + "ego-tree", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1532,6 +1738,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1555,6 +1770,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "slab" version = "0.4.12" @@ -1573,6 +1794,30 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1618,6 +1863,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + [[package]] name = "termtree" version = "0.5.1" @@ -2005,6 +2259,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-roots" version = "1.0.6" diff --git a/crates/idx-core/Cargo.toml b/crates/idx-core/Cargo.toml index 1ac22af..187ff54 100644 --- a/crates/idx-core/Cargo.toml +++ b/crates/idx-core/Cargo.toml @@ -34,3 +34,4 @@ rusqlite = { workspace = true, optional = true } quick-xml = { version = "0.37", optional = true } sha2 = { workspace = true, optional = true } zip = { workspace = true, optional = true } +scraper = { version = "0.27", default-features = false } diff --git a/crates/idx-core/src/api/ipot/parse.rs b/crates/idx-core/src/api/ipot/parse.rs index 4a49fa2..bc1d4a4 100644 --- a/crates/idx-core/src/api/ipot/parse.rs +++ b/crates/idx-core/src/api/ipot/parse.rs @@ -5,9 +5,11 @@ //! seller code, lots, value, average. Each code sits in a //! ``; the footer holds //! `T. Val`, `F. NVal`, `T.Lot` and `Avg`, and the date inputs echo the -//! range the source applied. +//! range the source applied. The page is parsed with html5ever, so markup +//! reads as a browser builds it. use chrono::NaiveDate; +use scraper::{ElementRef, Html, Selector}; use crate::broker::types::{BrokerFlow, BrokerKind}; use crate::error::IdxError; @@ -77,51 +79,6 @@ pub fn parse_figure(text: &str) -> Option<(i64, bool)> { Some((if negative { -value } else { value }, exponent > 0)) } -struct Cell { - class: Option, - text: String, -} - -fn strip_tags(html: &str) -> String { - let mut out = String::with_capacity(html.len()); - let mut in_tag = false; - for ch in html.chars() { - match ch { - '<' => in_tag = true, - '>' => in_tag = false, - _ if !in_tag => out.push(ch), - _ => {} - } - } - out.replace(" ", " ").trim().to_string() -} - -fn cells(row: &str) -> Vec { - let mut out = Vec::new(); - let mut rest = row; - while let Some(start) = rest.find("') else { - break; - }; - let inner_start = open_end + 1; - let Some(close) = after[inner_start..].find("") else { - break; - }; - let inner = &after[inner_start..inner_start + close]; - let class = inner.find("class=\"").and_then(|at| { - let value = &inner[at + 7..]; - value.find('"').map(|end| value[..end].to_string()) - }); - out.push(Cell { - class, - text: strip_tags(inner), - }); - rest = &after[inner_start + close + 5..]; - } - out -} - fn broker_kind(class: Option<&str>) -> Option { match class?.strip_prefix("text-")? { "local" => Some(BrokerKind::Local), @@ -131,174 +88,59 @@ fn broker_kind(class: Option<&str>) -> Option { } } -/// Whether `body` (lowercased) is exactly the markup IPOT serves, which -/// reads the same in any HTML parser: -/// -/// ```text -/// body := ws (row ws)* -/// row := "" ws (cell ws){9} "" -/// cell := ws ( text "" | text) ws "" -/// 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; - }; - rest = after.trim_start(); - } - true +/// The text an element shows, ` ` as a space, trimmed. +fn shown_text(element: ElementRef) -> String { + element + .text() + .collect::() + .replace('\u{a0}', " ") + .trim() + .to_string() } -fn canonical_row(row: &str) -> Option<&str> { - let mut rest = row.strip_prefix("")?; - for _ in 0..9 { - rest = canonical_cell(rest.trim_start())?; - } - rest.trim_start().strip_prefix("") +fn selector(css: &str) -> Selector { + Selector::parse(css).expect("a valid selector") } -fn canonical_cell(cell: &str) -> Option<&str> { - let rest = open_tag(cell, "td")?.trim_start(); - let rest = if rest.starts_with("")? - .trim_start() - } else { - plain_text(rest)? - }; - rest.strip_prefix("") -} - -/// The rest after ``. -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 one element matching `css` within `scope`; `None` for none or more. +fn only<'a>(scope: ElementRef<'a>, css: &str) -> Option> { + let selector = selector(css); + let mut found = scope.select(&selector); + match (found.next(), found.next()) { + (Some(one), None) => Some(one), + _ => None, } } -/// 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('&'); - (!text[..end].contains('>') && refs.all(|(at, _)| text[at..].starts_with(" "))) - .then_some(&text[end..]) +fn parse_error(what: &str) -> IdxError { + IdxError::ParseError(format!("ipot broker summary: {what}")) } -/// Elements whose content is not markup; IPOT's page ends with one empty -/// script, after everything read here. -const RAW_TEXT: [&str; 11] = [ - "`, -/// ``, `--!>`), an unclosed one, or other ` Option { - let mut out = String::with_capacity(html.len()); - let mut rest = html; - while let Some(at) = rest.find("")?; - let text = &comment[..end]; - if text.starts_with('>') || text.starts_with("->") || text.contains("--!>") { - return None; - } - rest = &comment[end + "-->".len()..]; - } - out.push_str(rest); - let lower = out.to_ascii_lowercase(); - let cut = RAW_TEXT - .iter() - .filter_map(|tag| lower.find(tag)) - .min() - .unwrap_or(lower.len()); - let read = &lower[..cut]; - let other = read - .match_indices(" Option { - let at = html.find(&format!("id=\"{id}\""))?; - let tag_start = html[..at].rfind('<')?; - let tag_end = at + html[at..].find('>')?; - let tag = &html[tag_start..tag_end]; - let value = &tag[tag.find("value=\"")? + 7..]; - NaiveDate::parse_from_str(&value[..value.find('"')?], "%m/%d/%Y").ok() -} - -/// Text after `label` up to the next tag, e.g. `6.6 T` for `T. Val :`. -fn footer<'a>(html: &'a str, label: &str) -> Option<&'a str> { - let rest = &html[html.find(label)? + label.len()..]; - Some(rest[..rest.find('<')?].trim()) +/// The `MM/DD/YYYY` value of the input with this id, the first one in the +/// page as `getElementById` finds it. +fn input_date(page: &Html, id: &str) -> Option { + let value = page + .select(&selector(&format!("input#{id}"))) + .next()? + .attr("value")?; + NaiveDate::parse_from_str(value.trim(), "%m/%d/%Y").ok() } +/// 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 +/// comments, attributes or other elements' content, is never read as such. pub fn parse_broker_summary(html: &str) -> Result { - let page = visible_markup(html).ok_or_else(|| { - IdxError::ParseError("ipot broker summary: unexpected markup in the page".to_string()) - })?; - let html = page.as_str(); - if html.to_ascii_lowercase().contains("invalid stock code") { + let page = Html::parse_fragment(html); + let shown = shown_text(page.root_element()).to_ascii_lowercase(); + if shown.contains("invalid stock code") { return Ok(IpotPage::Invalid); } - // Tags are matched case-insensitively on a lowercased copy; ASCII - // lowercasing keeps byte offsets, so slices of it index `html` too. - let lower = html.to_ascii_lowercase(); - let body = lower.find("").and_then(|start| { - lower[start..] - .find("") - .map(|len| (start + "".len(), start + len)) - }); - let single = lower.matches(" Result { value }) }; - // The body is whole rows (see `canonical_rows`); ranks must run in - // order, and a broker after a side's blank rows rejects the page. + // Rows must be whole and ranked in order: a skipped or merged row would + // shift brokers between ranks. A broker after a side's blank rows + // rejects the page too. + let span = selector("span"); let (mut buyers_ended, mut sellers_ended) = (false, false); - let mut rank = 0; - let mut at = body_start; - while at < body_end { - let rest = &lower[at..body_end]; - if rest.trim_start().is_empty() { - break; - } - let end = rest - .find("") - .map_or(rest.len(), |end| end + "".len()); - let original = &html[at..at + end]; - at += end; - rank += 1; - let row = cells(original); - if row.len() != 9 || row[4].text.parse::().ok() != Some(rank) { - return Err(IdxError::ParseError(format!( - "ipot broker summary: unexpected row {rank} ({} cells, rank {:?})", - row.len(), - row.get(4).map(|cell| cell.text.as_str()) - ))); + for (index, row) in body.child_elements().enumerate() { + let rank = index as u32 + 1; + let cells: Vec = row.child_elements().collect(); + let whole = row.value().name() == "tr" + && cells.len() == 9 + && cells.iter().all(|cell| cell.value().name() == "td"); + if !whole || shown_text(cells[4]).parse::().ok() != Some(rank) { + return Err(parse_error(&format!("unexpected row {rank}"))); } for (side, at, ended) in [ (&mut raw.buyers, 0, &mut buyers_ended), (&mut raw.sellers, 5, &mut sellers_ended), ] { - let code = row[at].text.to_ascii_uppercase(); + let code = shown_text(cells[at]).to_ascii_uppercase(); if code.is_empty() { *ended = true; continue; } if *ended { - return Err(IdxError::ParseError(format!( - "ipot broker summary: broker {code:?} after a blank row" - ))); + return Err(parse_error(&format!("broker {code:?} after a blank row"))); } if code.len() != 2 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) { - return Err(IdxError::ParseError(format!( - "ipot broker summary: unexpected broker code {code:?}" - ))); + return Err(parse_error(&format!("unexpected broker code {code:?}"))); } + let class = cells[at] + .select(&span) + .next() + .and_then(|span| span.attr("class")); side.push(BrokerFlow { rank, code, name: None, - kind: broker_kind(row[at].class.as_deref()), - lots: figure(&row[at + 1].text), - value: figure(&row[at + 2].text), - average_price: figure(&row[at + 3].text), + kind: broker_kind(class), + lots: figure(&shown_text(cells[at + 1])), + value: figure(&shown_text(cells[at + 2])), + average_price: figure(&shown_text(cells[at + 3])), }); } } - let mut total = |label: &str| footer(&html[body_end..], label).and_then(&mut figure); + // The footer's figures, e.g. `T. Val : 6.6 T`, each in its own span. + let footer: Vec = table + .select(&selector("tfoot span")) + .map(shown_text) + .collect(); + 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 :"); @@ -482,102 +324,184 @@ mod tests { )); } - #[test] - fn test_parse_broker_summary_rejects_rows_that_would_shift_ranks() { - let first_cell = "MG"; - let short_row = DAY.replacen(first_cell, "", 1); - let skipped_rank = DAY.replacen( - "1", - "2", - 1, - ); - let gap = DAY.replacen(">XL<", "><", 1); - // Rank 1 without its cell ends parses to no cells at all. + /// The BBCA October 16 page with `edit` applied around its rows: `rows` + /// holds where each `` of the body starts. + fn mutated(edit: impl Fn(&str, &[usize]) -> String) -> String { let body = DAY.find("").unwrap(); - let first_row_end = body + DAY[body..].find("").unwrap(); - let unclosed = DAY[..body].to_string() - + &DAY[body..first_row_end].replace("", "") - + &DAY[first_row_end..]; - // Rank 9 left open and rank 10 without cell ends: rank 10 would - // vanish into rank 9. let rows: Vec = DAY[body..] .match_indices("") .map(|(at, _)| body + at) .collect(); - let merged = DAY[..rows[8]].to_string() - + &DAY[rows[8] + "".len()..rows[9]].replace("", "") - + &DAY[rows[9]..]; - // An extra row in uppercase tags before rank 5. - let rank5 = DAY[..rows[4]].rfind("").unwrap(); - let extra_row = DAY[rank5..rows[4] + "".len()] - .replace("", "") - .replace("