From 9c0c857245726352a8502b1e4822a444ade96543 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 7 Jun 2026 15:51:10 +0300 Subject: [PATCH] fix(parser): use max of both attribute counts in similarity scoring denominator Candidates with fewer attributes than the original got inflated scores because the denominator counted candidate attributes only, while the extra-attributes penalty direction worked as intended. Using `max()` on both counts fixes the inflation while keeping the penalty. Closes #322 --- scrapling/parser.py | 4 ++- tests/parser/test_find_similar_advanced.py | 37 ++++++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/scrapling/parser.py b/scrapling/parser.py index 2b88af3..a075f2b 100644 --- a/scrapling/parser.py +++ b/scrapling/parser.py @@ -991,7 +991,9 @@ class Selector(SelectorsGeneration): SequenceMatcher(None, v, candidate_attributes.get(k, "")).ratio() for k, v in original_attributes.items() ) - checks += len(candidate_attributes) + # Using `max` so candidates with extra attributes are penalized and candidates + # with fewer attributes don't get inflated scores from a smaller denominator + checks += max(len(original_attributes), len(candidate_attributes)) else: if not candidate_attributes: # Both don't have attributes, this must mean something diff --git a/tests/parser/test_find_similar_advanced.py b/tests/parser/test_find_similar_advanced.py index 099b0dd..95e1e69 100644 --- a/tests/parser/test_find_similar_advanced.py +++ b/tests/parser/test_find_similar_advanced.py @@ -2,6 +2,7 @@ Tests for Selector.find_similar() with non-default parameters. Target file: tests/parser/test_general.py (append to TestSimilarElements class) """ + import pytest from scrapling import Selector @@ -61,14 +62,10 @@ class TestFindSimilarAdvanced: first = product_page.css("div.product")[0] # Ignore both data-price and data-category → only class matters → all 3 divs match ignore_all_data = first.find_similar( - similarity_threshold=0.2, - ignore_attributes=["data-price", "data-category"] + similarity_threshold=0.2, ignore_attributes=["data-price", "data-category"] ) # Ignore nothing → data-category difference (fruit vs veggie) may reduce matches - ignore_nothing = first.find_similar( - similarity_threshold=0.9, - ignore_attributes=[] - ) + ignore_nothing = first.find_similar(similarity_threshold=0.9, ignore_attributes=[]) assert len(ignore_all_data) >= len(ignore_nothing) def test_find_similar_on_text_node_returns_empty(self, product_page): @@ -76,3 +73,31 @@ class TestFindSimilarAdvanced: text_node = product_page.css(".name::text")[0] result = text_node.find_similar() assert len(result) == 0 + + def test_find_similar_attribute_count_mismatch_scoring(self): + """The similarity denominator uses max() of both attribute counts, so candidates + with fewer attributes don't get inflated scores and candidates with extra + attributes stay penalized.""" + html = """ + +
+
Alpha
+
Beta
+
Gamma
+
Delta
+
+ + """ + page = Selector(html, adaptive=False) + first = page.css("div.card")[0] # Alpha + + similar = first.find_similar(similarity_threshold=0.9, ignore_attributes=[]) + texts = {el.text for el in similar} + + # An exact attribute match must pass + assert "Delta" in texts + # Beta matches 1 of Alpha's 4 attributes; the old denominator counted candidate + # attributes only, inflating it to a perfect score (1.0 / 1) + assert "Beta" not in texts + # Gamma's extra attribute dilutes the score (4.0 / 5) - the intentional penalty + assert "Gamma" not in texts