1mod difflib;
21
22use scraper::{ElementRef, Html, Selector as CssSelector};
23use serde::{Deserialize, Serialize};
24use std::collections::BTreeMap;
25
26pub use difflib::str_ratio;
27
28fn 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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct ElementFingerprint {
55 pub tag: String,
56 pub attributes: BTreeMap<String, String>,
59 pub text: Option<String>,
61 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 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
73 serde_json::from_str(json)
74 }
75
76 pub fn to_json(&self) -> String {
78 serde_json::to_string(self).expect("fingerprint serialization is infallible")
79 }
80}
81
82#[derive(Debug, thiserror::Error)]
84pub enum Error {
85 #[error("invalid CSS selector: {0}")]
86 InvalidSelector(String),
87}
88
89pub struct AdaptiveDocument {
91 html: Html,
92}
93
94pub 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 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 pub fn css_first(&self, selector: &str) -> Result<Option<AdaptiveElement<'_>>, Error> {
119 Ok(self.css(selector)?.into_iter().next())
120 }
121
122 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 pub fn tag(&self) -> String {
158 self.element.value().name().to_string()
159 }
160
161 pub fn text(&self) -> String {
163 self.element.text().collect::<String>().trim().to_string()
164 }
165
166 pub fn attribute(&self, name: &str) -> Option<&str> {
168 self.element.value().attr(name)
169 }
170
171 pub fn html(&self) -> String {
173 self.element.inner_html()
174 }
175
176 pub fn fingerprint(&self) -> ElementFingerprint {
178 candidate_fingerprint(self.element)
179 }
180
181 pub fn as_element_ref(&self) -> ElementRef<'a> {
183 self.element
184 }
185}
186
187fn 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 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 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
282fn 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 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 ((score / checks as f64) * 100.0 * 100.0).round() / 100.0
349}
350
351fn 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 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 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 let saved = doc1.css("td.cell").unwrap()[2].fingerprint();
447
448 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 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 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 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}