Skip to main content

adaptive_selector/
lib.rs

1//! adaptive-selector — CSS selectors that survive website redesigns.
2//!
3//! A faithful Rust port of Scrapling's adaptive relocation engine. Save an
4//! element's fingerprint once; when the page changes and your CSS selector
5//! breaks, relocate the element by structural similarity instead of by
6//! selector.
7//!
8//! ```no_run
9//! use adaptive_selector::{AdaptiveDocument, SimilarityThreshold};
10//!
11//! let doc = AdaptiveDocument::parse(r#"<div class="price">$9.99</div>"#);
12//! let saved = doc.css_first(".price").unwrap().expect("price exists").fingerprint();
13//!
14//! // ... the site redesigns, `.price` is now `.product-price__current` ...
15//! let doc2 = AdaptiveDocument::parse(r#"<span class="product-price__current">$9.99</span>"#);
16//! let found = doc2.relocate(&saved, SimilarityThreshold::default());
17//! assert_eq!(found.first().map(|e| e.text()), Some("$9.99".to_string()));
18//! ```
19
20mod difflib;
21
22use scraper::{ElementRef, Html, Selector as CssSelector};
23use serde::{Deserialize, Serialize};
24use std::collections::BTreeMap;
25
26pub use difflib::str_ratio;
27
28/// Element-wise ratio over two sequences — exactly what Python does when it
29/// calls `SequenceMatcher` on tuples of strings (as Scrapling does for
30/// attributes, siblings, and paths). Joining with a separator would
31/// manufacture cross-boundary matches (`["btn","primary"]` vs
32/// `["btn primary"]` would score 0.91 joined, 0.0 element-wise), so the
33/// generic matcher is used directly on the slices.
34fn seq_ratio<T: AsRef<str> + PartialEq + std::hash::Hash + Eq + Clone>(a: &[T], b: &[T]) -> f64 {
35    let a_owned: Vec<T> = a.to_vec();
36    let b_owned: Vec<T> = b.to_vec();
37    difflib::SequenceMatcher::new(&a_owned, &b_owned).ratio()
38}
39
40/// Minimum similarity (0–100) for [`AdaptiveDocument::relocate`] to accept a
41/// candidate. Scrapling's default is 40.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct SimilarityThreshold(pub f64);
44
45impl Default for SimilarityThreshold {
46    fn default() -> Self {
47        Self(40.0)
48    }
49}
50
51/// A serialized element fingerprint — everything the similarity scorer needs.
52/// Store it anywhere (file, DB, JSON column); it is plain data.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct ElementFingerprint {
55    pub tag: String,
56    /// Attribute name → value, whitespace-stripped, empties dropped (BTreeMap
57    /// so serialization is deterministic).
58    pub attributes: BTreeMap<String, String>,
59    /// The element's own leading text (`element.text` in lxml terms), stripped.
60    pub text: Option<String>,
61    /// Root-to-element tag path.
62    pub path: Vec<String>,
63    pub parent_name: Option<String>,
64    pub parent_attribs: Option<BTreeMap<String, String>>,
65    pub parent_text: Option<String>,
66    pub siblings: Option<Vec<String>>,
67    pub children: Option<Vec<String>>,
68}
69
70impl ElementFingerprint {
71    /// Parse a fingerprint previously written with `serde_json`.
72    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
73        serde_json::from_str(json)
74    }
75
76    /// Serialize the fingerprint to a JSON string.
77    pub fn to_json(&self) -> String {
78        serde_json::to_string(self).expect("fingerprint serialization is infallible")
79    }
80}
81
82/// Errors returned by selector parsing and relocation.
83#[derive(Debug, thiserror::Error)]
84pub enum Error {
85    #[error("invalid CSS selector: {0}")]
86    InvalidSelector(String),
87}
88
89/// A parsed HTML document with adaptive-relocation support.
90pub struct AdaptiveDocument {
91    html: Html,
92}
93
94/// A located element: the scraper `ElementRef` plus its owner document's tree.
95pub struct AdaptiveElement<'a> {
96    element: ElementRef<'a>,
97}
98
99impl AdaptiveDocument {
100    pub fn parse(html: &str) -> Self {
101        Self {
102            html: Html::parse_document(html),
103        }
104    }
105
106    /// All elements matching a CSS selector.
107    pub fn css(&self, selector: &str) -> Result<Vec<AdaptiveElement<'_>>, Error> {
108        let sel =
109            CssSelector::parse(selector).map_err(|e| Error::InvalidSelector(e.to_string()))?;
110        Ok(self
111            .html
112            .select(&sel)
113            .map(|element| AdaptiveElement { element })
114            .collect())
115    }
116
117    /// First element matching a CSS selector.
118    pub fn css_first(&self, selector: &str) -> Result<Option<AdaptiveElement<'_>>, Error> {
119        Ok(self.css(selector)?.into_iter().next())
120    }
121
122    /// Find the elements most similar to `fingerprint`, accepting anything at
123    /// or above `threshold`. Returns the best-scoring group (there may be
124    /// ties), highest first; empty when nothing clears the bar.
125    pub fn relocate(
126        &self,
127        fingerprint: &ElementFingerprint,
128        threshold: SimilarityThreshold,
129    ) -> Vec<AdaptiveElement<'_>> {
130        let mut best_score = f64::NEG_INFINITY;
131        let mut best: Vec<ElementRef<'_>> = Vec::new();
132        for node in self.html.tree.nodes() {
133            let Some(element) = ElementRef::wrap(node) else {
134                continue;
135            };
136            let score = similarity_score(fingerprint, &candidate_fingerprint(element));
137            if score > best_score {
138                best_score = score;
139                best.clear();
140                best.push(element);
141            } else if score == best_score {
142                best.push(element);
143            }
144        }
145        if best_score >= threshold.0 {
146            best.into_iter()
147                .map(|element| AdaptiveElement { element })
148                .collect()
149        } else {
150            Vec::new()
151        }
152    }
153}
154
155impl<'a> AdaptiveElement<'a> {
156    /// The element's tag name.
157    pub fn tag(&self) -> String {
158        self.element.value().name().to_string()
159    }
160
161    /// All descendant text content, whitespace-collapsed.
162    pub fn text(&self) -> String {
163        self.element.text().collect::<String>().trim().to_string()
164    }
165
166    /// An attribute value by name, if present.
167    pub fn attribute(&self, name: &str) -> Option<&str> {
168        self.element.value().attr(name)
169    }
170
171    /// Inner HTML of the element.
172    pub fn html(&self) -> String {
173        self.element.inner_html()
174    }
175
176    /// Capture the fingerprint for later relocation.
177    pub fn fingerprint(&self) -> ElementFingerprint {
178        candidate_fingerprint(self.element)
179    }
180
181    /// The wrapped `scraper` element, for anything this API doesn't cover.
182    pub fn as_element_ref(&self) -> ElementRef<'a> {
183        self.element
184    }
185}
186
187/// Build a fingerprint from a live element — this mirrors Scrapling's
188/// `_StorageTools.element_to_dict` exactly (same fields, same cleaning rules),
189/// with one documented divergence: scraper's tree has no parent pointers
190/// upward from the root, so the root element's parent fields are `None` where
191/// lxml would report the synthetic root; this only affects scoring when the
192/// SAVED element was itself the document root.
193fn candidate_fingerprint(element: ElementRef<'_>) -> ElementFingerprint {
194    let value = element.value();
195    let mut attributes = BTreeMap::new();
196    for (name, attr_value) in value.attrs() {
197        let stripped = attr_value.trim();
198        if !stripped.is_empty() {
199            attributes.insert(name.to_string(), stripped.to_string());
200        }
201    }
202    // lxml's `element.text` is the FIRST TEXT NODE CHILD only — not the first
203    // non-empty descendant text. For `<div><span>hi</span>world</div>`, lxml
204    // gives None (first child is an element); a "first non-empty descendant"
205    // read would give "hi" and skew parent-text scoring. Match lxml exactly:
206    // walk direct children, take the first Text node, strip; empty -> None.
207    let first_text = element
208        .children()
209        .find_map(|child| match child.value() {
210            scraper::node::Node::Text(t) => Some(t.trim().to_string()),
211            _ => None,
212        })
213        .filter(|t| !t.is_empty());
214
215    // Path: root-to-element tags.
216    let mut path = Vec::new();
217    for ancestor in element.ancestors() {
218        if let Some(ancestor_el) = ElementRef::wrap(ancestor) {
219            path.push(ancestor_el.value().name().to_string());
220        }
221    }
222    path.reverse();
223
224    let parent = element.parent().and_then(ElementRef::wrap);
225    let (parent_name, parent_attribs, parent_text, siblings) = match parent {
226        Some(parent_el) => {
227            let mut parent_attribs = BTreeMap::new();
228            for (name, attr_value) in parent_el.value().attrs() {
229                parent_attribs.insert(name.to_string(), attr_value.to_string());
230            }
231            let parent_text = parent_el
232                .text()
233                .map(|t| t.trim())
234                .find(|t| !t.is_empty())
235                .map(|t| t.to_string());
236            let mut siblings = Vec::new();
237            for sibling in parent_el.children() {
238                if let Some(sibling_el) = ElementRef::wrap(sibling) {
239                    if sibling_el != element {
240                        siblings.push(sibling_el.value().name().to_string());
241                    }
242                }
243            }
244            (
245                Some(parent_el.value().name().to_string()),
246                Some(parent_attribs),
247                parent_text,
248                if siblings.is_empty() {
249                    None
250                } else {
251                    Some(siblings)
252                },
253            )
254        }
255        None => (None, None, None, None),
256    };
257
258    let mut children = Vec::new();
259    for child in element.children() {
260        if let Some(child_el) = ElementRef::wrap(child) {
261            children.push(child_el.value().name().to_string());
262        }
263    }
264
265    ElementFingerprint {
266        tag: value.name().to_string(),
267        attributes,
268        text: first_text,
269        path,
270        parent_name,
271        parent_attribs,
272        parent_text,
273        siblings,
274        children: if children.is_empty() {
275            None
276        } else {
277            Some(children)
278        },
279    }
280}
281
282/// The similarity score, 0–100 — a faithful port of Scrapling's
283/// `__calculate_similarity_score`. Each comparison adds a score in [0,1] and
284/// one "check"; the result is (score/checks) * 100, rounded to 2 decimals.
285fn similarity_score(original: &ElementFingerprint, candidate: &ElementFingerprint) -> f64 {
286    let mut score = 0.0f64;
287    let mut checks = 0usize;
288
289    score += if original.tag == candidate.tag {
290        1.0
291    } else {
292        0.0
293    };
294    checks += 1;
295
296    if let Some(original_text) = &original.text {
297        score += str_ratio(original_text, candidate.text.as_deref().unwrap_or(""));
298        checks += 1;
299    }
300
301    score += dict_diff(&original.attributes, &candidate.attributes);
302    checks += 1;
303
304    // Separate similarity for high-signal attributes — survives full
305    // structural changes where paths and siblings mean nothing.
306    for attrib in ["class", "id", "href", "src"] {
307        if let Some(original_value) = original.attributes.get(attrib) {
308            score += str_ratio(
309                original_value,
310                candidate
311                    .attributes
312                    .get(attrib)
313                    .map(String::as_str)
314                    .unwrap_or(""),
315            );
316            checks += 1;
317        }
318    }
319
320    score += seq_ratio(&original.path, &candidate.path);
321    checks += 1;
322
323    if let Some(parent_name) = &original.parent_name {
324        if candidate.parent_name.is_some() {
325            score += str_ratio(parent_name, candidate.parent_name.as_deref().unwrap_or(""));
326            checks += 1;
327            let empty_map: BTreeMap<String, String> = BTreeMap::new();
328            score += dict_diff(
329                original.parent_attribs.as_ref().unwrap_or(&empty_map),
330                candidate.parent_attribs.as_ref().unwrap_or(&empty_map),
331            );
332            checks += 1;
333            if let Some(parent_text) = &original.parent_text {
334                score += str_ratio(parent_text, candidate.parent_text.as_deref().unwrap_or(""));
335                checks += 1;
336            }
337        }
338    }
339
340    if let Some(siblings) = &original.siblings {
341        let empty: Vec<String> = Vec::new();
342        let candidate_siblings = candidate.siblings.as_ref().unwrap_or(&empty);
343        score += seq_ratio(siblings, candidate_siblings);
344        checks += 1;
345    }
346
347    // How % sure? Scrapling rounds to 2 decimals.
348    ((score / checks as f64) * 100.0 * 100.0).round() / 100.0
349}
350
351/// Scrapling's `__calculate_dict_diff`: keys-sequence ratio * 0.5 +
352/// values-sequence ratio * 0.5.
353fn dict_diff(a: &BTreeMap<String, String>, b: &BTreeMap<String, String>) -> f64 {
354    let keys = seq_ratio(&a.keys().collect::<Vec<_>>(), &b.keys().collect::<Vec<_>>());
355    let values = seq_ratio(
356        &a.values().collect::<Vec<_>>(),
357        &b.values().collect::<Vec<_>>(),
358    );
359    keys * 0.5 + values * 0.5
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    const BEFORE: &str = r#"
367        <html><body>
368            <div class="product">
369                <h2 class="title">Widget</h2>
370                <span class="price">$9.99</span>
371            </div>
372        </body></html>
373    "#;
374    const REDESIGNED: &str = r#"
375        <html><body>
376            <section class="product-card">
377                <h3 class="product-title">Widget</h3>
378                <span class="product-price__current">$9.99</span>
379            </section>
380        </body></html>
381    "#;
382
383    #[test]
384    fn relocates_after_redesign() {
385        let doc = AdaptiveDocument::parse(BEFORE);
386        let saved = doc
387            .css_first("span.price")
388            .unwrap()
389            .expect("price exists before")
390            .fingerprint();
391
392        let doc2 = AdaptiveDocument::parse(REDESIGNED);
393        let found = doc2.relocate(&saved, SimilarityThreshold::default());
394        assert!(
395            !found.is_empty(),
396            "redesign should still relocate the price"
397        );
398        assert_eq!(found[0].text(), "$9.99");
399        assert_eq!(found[0].attribute("class"), Some("product-price__current"));
400    }
401
402    #[test]
403    fn selector_that_still_matches_outperforms_relocation() {
404        // Sanity: when the selector still works, plain css finds it directly.
405        let doc = AdaptiveDocument::parse(BEFORE);
406        assert_eq!(
407            doc.css_first("span.price").unwrap().unwrap().text(),
408            "$9.99"
409        );
410    }
411
412    #[test]
413    fn threshold_rejects_weak_matches() {
414        let doc = AdaptiveDocument::parse(BEFORE);
415        let saved = doc.css_first("span.price").unwrap().unwrap().fingerprint();
416        let unrelated =
417            AdaptiveDocument::parse("<html><body><footer>legal text</footer></body></html>");
418        assert!(unrelated
419            .relocate(&saved, SimilarityThreshold(99.0))
420            .is_empty());
421    }
422
423    #[test]
424    fn fingerprint_round_trips_through_json() {
425        let doc = AdaptiveDocument::parse(BEFORE);
426        let fp = doc.css_first("span.price").unwrap().unwrap().fingerprint();
427        let json = fp.to_json();
428        let back = ElementFingerprint::from_json(&json).unwrap();
429        assert_eq!(fp, back);
430    }
431}
432
433#[cfg(test)]
434mod adversarial_tests {
435    use super::*;
436
437    #[test]
438    fn relocates_the_right_item_in_a_near_duplicate_list() {
439        // Ten structurally identical rows; only the text differs. A fingerprint
440        // saved from row 3 must relocate to row 3, not row 1.
441        let rows: Vec<String> = (1..=10)
442            .map(|i| format!(r#"<tr><td class="cell">Item {i:02}</td></tr>"#))
443            .collect();
444        let doc1 = AdaptiveDocument::parse(&format!("<table>{}</table>", rows.join("")));
445        // css_first would grab row 1; deliberately save from row 3
446        let saved = doc1.css("td.cell").unwrap()[2].fingerprint();
447
448        // Redesign: table -> div list, same text order
449        let divs: Vec<String> = (1..=10)
450            .map(|i| format!(r#"<div class="cell">Item {i:02}</div>"#))
451            .collect();
452        let doc2 = AdaptiveDocument::parse(&format!(r#"<main>{}</main>"#, divs.join("")));
453        let found = doc2.relocate(&saved, SimilarityThreshold::default());
454        assert_eq!(
455            found.len(),
456            1,
457            "near-duplicate rows must resolve to exactly one"
458        );
459        assert_eq!(found[0].text(), "Item 03");
460    }
461
462    #[test]
463    fn identical_elements_relocate_as_a_group() {
464        // Scrapling returns ALL elements sharing the top score; so do we.
465        let doc1 =
466            AdaptiveDocument::parse(r#"<ul><li class="x">same</li><li class="x">same</li></ul>"#);
467        let saved = doc1.css_first("li.x").unwrap().unwrap().fingerprint();
468        let doc2 =
469            AdaptiveDocument::parse(r#"<ul><li class="y">same</li><li class="y">same</li></ul>"#);
470        let found = doc2.relocate(&saved, SimilarityThreshold::default());
471        assert_eq!(found.len(), 2, "tied top-score elements all return");
472    }
473
474    #[test]
475    fn empty_document_relocates_nothing() {
476        let doc1 = AdaptiveDocument::parse(r#"<p class="a">text</p>"#);
477        let saved = doc1.css_first("p.a").unwrap().unwrap().fingerprint();
478        let doc2 = AdaptiveDocument::parse("");
479        assert!(doc2
480            .relocate(&saved, SimilarityThreshold::default())
481            .is_empty());
482        // and the empty-vs-empty fingerprint must not panic
483        let empty = AdaptiveDocument::parse("");
484        assert!(empty.css_first("p").unwrap().is_none());
485    }
486
487    #[test]
488    fn threshold_zero_still_requires_a_match_group() {
489        // threshold 0 must accept any score >= 0 — but on an empty tree there
490        // are no elements at all, so still empty. Guard the NEG_INFINITY path.
491        let doc1 = AdaptiveDocument::parse(r#"<p class="a">text</p>"#);
492        let saved = doc1.css_first("p.a").unwrap().unwrap().fingerprint();
493        let doc2 = AdaptiveDocument::parse(r#"<div>unrelated</div>"#);
494        let found = doc2.relocate(&saved, SimilarityThreshold(0.0));
495        assert!(
496            !found.is_empty(),
497            "score 0 accepted at threshold 0? checks: div has elements"
498        );
499    }
500
501    #[test]
502    fn fingerprint_is_stable_across_parses() {
503        let html = r#"<div class="p"><span id="s">x</span></div>"#;
504        let a = AdaptiveDocument::parse(html)
505            .css_first("#s")
506            .unwrap()
507            .unwrap()
508            .fingerprint();
509        let b = AdaptiveDocument::parse(html)
510            .css_first("#s")
511            .unwrap()
512            .unwrap()
513            .fingerprint();
514        assert_eq!(a, b);
515    }
516}