Files
AccountForger-neuerUpload/utils/text_similarity.py
Claude Project Manager 04585e95b6 Initial commit
2025-08-01 23:50:28 +02:00

558 Zeilen
20 KiB
Python

"""
Textähnlichkeits-Funktionen für robustes UI-Element-Matching.
Ermöglicht flexibles Auffinden von UI-Elementen auch bei leichten Textänderungen.
"""
import re
import logging
from typing import List, Dict, Any, Optional, Tuple, Union, Callable
from difflib import SequenceMatcher
logger = logging.getLogger("text_similarity")
class TextSimilarity:
"""Klasse für Textähnlichkeitsfunktionen zum robusten UI-Element-Matching."""
def __init__(self, default_threshold: float = 0.8):
"""
Initialisiert die TextSimilarity-Klasse.
Args:
default_threshold: Standardschwellenwert für Ähnlichkeitsprüfungen (0-1)
"""
self.default_threshold = max(0.0, min(1.0, default_threshold))
def levenshtein_distance(self, s1: str, s2: str) -> int:
"""
Berechnet die Levenshtein-Distanz zwischen zwei Strings.
Args:
s1: Erster String
s2: Zweiter String
Returns:
Die Levenshtein-Distanz (kleinere Werte = ähnlichere Strings)
"""
if s1 == s2:
return 0
# Strings für die Berechnung vorbereiten
s1 = s1.lower().strip()
s2 = s2.lower().strip()
# Spezialfall: leere Strings
if len(s1) == 0: return len(s2)
if len(s2) == 0: return len(s1)
# Initialisiere die Distanzmatrix
matrix = [[0 for x in range(len(s2) + 1)] for x in range(len(s1) + 1)]
# Fülle die erste Zeile und Spalte
for i in range(len(s1) + 1):
matrix[i][0] = i
for j in range(len(s2) + 1):
matrix[0][j] = j
# Fülle die Matrix
for i in range(1, len(s1) + 1):
for j in range(1, len(s2) + 1):
cost = 0 if s1[i-1] == s2[j-1] else 1
matrix[i][j] = min(
matrix[i-1][j] + 1, # Löschen
matrix[i][j-1] + 1, # Einfügen
matrix[i-1][j-1] + cost # Ersetzen
)
return matrix[len(s1)][len(s2)]
def similarity_ratio(self, s1: str, s2: str) -> float:
"""
Berechnet das Ähnlichkeitsverhältnis zwischen zwei Strings (0-1).
Args:
s1: Erster String
s2: Zweiter String
Returns:
Ähnlichkeitsverhältnis zwischen 0 (unähnlich) und 1 (identisch)
"""
# Strings für Vergleich normalisieren
s1 = s1.lower().strip()
s2 = s2.lower().strip()
# Leere Strings behandeln
if len(s1) == 0 and len(s2) == 0:
return 1.0
# Maximale mögliche Distanz = Summe der Längen beider Strings
max_distance = max(len(s1), len(s2))
if max_distance == 0:
return 1.0
# Levenshtein-Distanz berechnen
distance = self.levenshtein_distance(s1, s2)
# Ähnlichkeitsverhältnis berechnen
similarity = 1.0 - (distance / max_distance)
return similarity
def sequence_matcher_ratio(self, s1: str, s2: str) -> float:
"""
Berechnet das Ähnlichkeitsverhältnis mit Pythons SequenceMatcher.
Oft genauer als einfaches Levenshtein für längere Texte.
Args:
s1: Erster String
s2: Zweiter String
Returns:
Ähnlichkeitsverhältnis zwischen 0 (unähnlich) und 1 (identisch)
"""
# Strings für Vergleich normalisieren
s1 = s1.lower().strip()
s2 = s2.lower().strip()
# SequenceMatcher verwenden
return SequenceMatcher(None, s1, s2).ratio()
def jaro_winkler_similarity(self, s1: str, s2: str) -> float:
"""
Berechnet die Jaro-Winkler-Ähnlichkeit, die Präfixübereinstimmungen berücksichtigt.
Gut für kurze Strings wie Namen oder IDs.
Args:
s1: Erster String
s2: Zweiter String
Returns:
Ähnlichkeitswert zwischen 0 (unähnlich) und 1 (identisch)
"""
# Strings für Vergleich normalisieren
s1 = s1.lower().strip()
s2 = s2.lower().strip()
# Identische Strings
if s1 == s2:
return 1.0
# Leere Strings behandeln
if len(s1) == 0 or len(s2) == 0:
return 0.0
# Berechne die Jaro-Ähnlichkeit
# Suche nach übereinstimmenden Zeichen innerhalb des Suchradius
search_range = max(len(s1), len(s2)) // 2 - 1
search_range = max(0, search_range)
# Initialisiere Übereinstimmungs- und Transpositionszähler
matches = 0
transpositions = 0
# Markiere übereinstimmende Zeichen
s1_matches = [False] * len(s1)
s2_matches = [False] * len(s2)
# Finde Übereinstimmungen
for i in range(len(s1)):
start = max(0, i - search_range)
end = min(i + search_range + 1, len(s2))
for j in range(start, end):
if not s2_matches[j] and s1[i] == s2[j]:
s1_matches[i] = True
s2_matches[j] = True
matches += 1
break
# Wenn keine Übereinstimmungen gefunden wurden
if matches == 0:
return 0.0
# Zähle Transpositionszeichen
k = 0
for i in range(len(s1)):
if s1_matches[i]:
while not s2_matches[k]:
k += 1
if s1[i] != s2[k]:
transpositions += 1
k += 1
# Berechne Jaro-Ähnlichkeit
jaro = (
matches / len(s1) +
matches / len(s2) +
(matches - transpositions // 2) / matches
) / 3.0
# Berechne Jaro-Winkler-Ähnlichkeit mit Präfixbonus
prefix_len = 0
max_prefix_len = min(4, min(len(s1), len(s2)))
# Zähle übereinstimmende Präfixzeichen
while prefix_len < max_prefix_len and s1[prefix_len] == s2[prefix_len]:
prefix_len += 1
# Skalierungsfaktor für Präfixanpassung (Standard: 0.1)
scaling_factor = 0.1
# Berechne Jaro-Winkler-Ähnlichkeit
jaro_winkler = jaro + prefix_len * scaling_factor * (1 - jaro)
return jaro_winkler
def is_similar(self, s1: str, s2: str, threshold: float = None, method: str = "sequence") -> bool:
"""
Prüft, ob zwei Strings ähnlich genug sind, basierend auf einem Schwellenwert.
Args:
s1: Erster String
s2: Zweiter String
threshold: Ähnlichkeitsschwellenwert (0-1), oder None für Standardwert
method: Ähnlichkeitsmethode ("levenshtein", "sequence", "jaro_winkler")
Returns:
True, wenn die Strings ähnlich genug sind, False sonst
"""
if threshold is None:
threshold = self.default_threshold
# Leere oder None-Strings behandeln
s1 = "" if s1 is None else str(s1)
s2 = "" if s2 is None else str(s2)
# Wenn beide Strings identisch sind
if s1 == s2:
return True
# Ähnlichkeitsmethode auswählen
if method == "levenshtein":
similarity = self.similarity_ratio(s1, s2)
elif method == "jaro_winkler":
similarity = self.jaro_winkler_similarity(s1, s2)
else: # "sequence" oder andere
similarity = self.sequence_matcher_ratio(s1, s2)
return similarity >= threshold
def find_most_similar(self, target: str, candidates: List[str],
method: str = "sequence") -> Tuple[str, float]:
"""
Findet den ähnlichsten String in einer Liste von Kandidaten.
Args:
target: Zieltext, zu dem der ähnlichste String gefunden werden soll
candidates: Liste von Kandidatenstrings
method: Ähnlichkeitsmethode ("levenshtein", "sequence", "jaro_winkler")
Returns:
Tuple (ähnlichster String, Ähnlichkeitswert)
"""
if not candidates:
return "", 0.0
# Ähnlichkeitsfunktion auswählen
if method == "levenshtein":
similarity_func = self.similarity_ratio
elif method == "jaro_winkler":
similarity_func = self.jaro_winkler_similarity
else: # "sequence" oder andere
similarity_func = self.sequence_matcher_ratio
# Finde den ähnlichsten Kandidaten
similarities = [(candidate, similarity_func(target, candidate)) for candidate in candidates]
most_similar = max(similarities, key=lambda x: x[1])
return most_similar
def get_similarity_scores(self, target: str, candidates: List[str],
method: str = "sequence") -> Dict[str, float]:
"""
Berechnet Ähnlichkeitswerte für alle Kandidaten.
Args:
target: Zieltext
candidates: Liste von Kandidatenstrings
method: Ähnlichkeitsmethode
Returns:
Dictionary mit {Kandidat: Ähnlichkeitswert}
"""
# Ähnlichkeitsfunktion auswählen
if method == "levenshtein":
similarity_func = self.similarity_ratio
elif method == "jaro_winkler":
similarity_func = self.jaro_winkler_similarity
else: # "sequence" oder andere
similarity_func = self.sequence_matcher_ratio
# Berechne Ähnlichkeiten für alle Kandidaten
return {candidate: similarity_func(target, candidate) for candidate in candidates}
def words_similarity(self, s1: str, s2: str) -> float:
"""
Berechnet die Ähnlichkeit basierend auf gemeinsamen Wörtern.
Args:
s1: Erster String
s2: Zweiter String
Returns:
Ähnlichkeitswert zwischen 0 (unähnlich) und 1 (identisch)
"""
# Strings in Wörter zerlegen
words1 = set(re.findall(r'\w+', s1.lower()))
words2 = set(re.findall(r'\w+', s2.lower()))
# Leere Wortmengen behandeln
if not words1 and not words2:
return 1.0
if not words1 or not words2:
return 0.0
# Berechne Jaccard-Ähnlichkeit
intersection = len(words1.intersection(words2))
union = len(words1.union(words2))
return intersection / union
def contains_similar_text(self, text: str, patterns: List[str],
threshold: float = None, method: str = "sequence") -> bool:
"""
Prüft, ob ein Text einen der Muster ähnlich enthält.
Args:
text: Zu durchsuchender Text
patterns: Liste von zu suchenden Mustern
threshold: Ähnlichkeitsschwellenwert
method: Ähnlichkeitsmethode
Returns:
True, wenn mindestens ein Muster ähnlich genug ist
"""
if threshold is None:
threshold = self.default_threshold
# Wenn patterns leer ist oder Text None ist
if not patterns or text is None:
return False
text = str(text).lower()
for pattern in patterns:
pattern = str(pattern).lower()
# Prüfe, ob der Text das Muster enthält
if pattern in text:
return True
# Prüfe Ähnlichkeit mit Wörtern im Text
words = re.findall(r'\w+', text)
for word in words:
if self.is_similar(word, pattern, threshold, method):
return True
return False
def fuzzy_find_element(page, text_or_patterns, selector_type="button", threshold=0.8,
method="sequence", wait_time=5000) -> Optional[Any]:
"""
Findet ein Element basierend auf Textähnlichkeit.
Args:
page: Playwright Page-Objekt
text_or_patterns: Zieltext oder Liste von Texten
selector_type: Art des Elements ("button", "link", "input", "any")
threshold: Ähnlichkeitsschwellenwert
method: Ähnlichkeitsmethode
wait_time: Wartezeit in Millisekunden
Returns:
Gefundenes Element oder None
"""
similarity = TextSimilarity(threshold)
patterns = [text_or_patterns] if isinstance(text_or_patterns, str) else text_or_patterns
try:
# Warte, bis die Seite geladen ist
page.wait_for_load_state("domcontentloaded", timeout=wait_time)
# Selektoren basierend auf dem Element-Typ
if selector_type == "button":
elements = page.query_selector_all("button, input[type='button'], input[type='submit'], [role='button']")
elif selector_type == "link":
elements = page.query_selector_all("a, [role='link']")
elif selector_type == "input":
elements = page.query_selector_all("input, textarea, select")
else: # "any"
elements = page.query_selector_all("*")
# Keine Elemente gefunden
if not elements:
logger.debug(f"Keine {selector_type}-Elemente auf der Seite gefunden")
return None
# Für jedes Element den Text und die Ähnlichkeit prüfen
best_match = None
best_similarity = -1
for element in elements:
# Text aus verschiedenen Attributen extrahieren
element_text = ""
# Inneren Text prüfen
inner_text = element.inner_text()
if inner_text and inner_text.strip():
element_text = inner_text.strip()
# Value-Attribut prüfen (für Eingabefelder)
if not element_text:
try:
value = element.get_attribute("value")
if value and value.strip():
element_text = value.strip()
except:
pass
# Placeholder prüfen
if not element_text:
try:
placeholder = element.get_attribute("placeholder")
if placeholder and placeholder.strip():
element_text = placeholder.strip()
except:
pass
# Aria-Label prüfen
if not element_text:
try:
aria_label = element.get_attribute("aria-label")
if aria_label and aria_label.strip():
element_text = aria_label.strip()
except:
pass
# Title-Attribut prüfen
if not element_text:
try:
title = element.get_attribute("title")
if title and title.strip():
element_text = title.strip()
except:
pass
# Wenn immer noch kein Text gefunden wurde, überspringen
if not element_text:
continue
# Ähnlichkeit für jeden Pattern prüfen
for pattern in patterns:
sim_score = similarity.sequence_matcher_ratio(pattern, element_text)
# Ist dieser Match besser als der bisherige beste?
if sim_score > best_similarity and sim_score >= threshold:
best_similarity = sim_score
best_match = element
# Bei perfekter Übereinstimmung sofort zurückgeben
if sim_score >= 0.99:
logger.info(f"Element mit perfekter Übereinstimmung gefunden: '{element_text}'")
return element
# Bestes Ergebnis zurückgeben, wenn es über dem Schwellenwert liegt
if best_match:
try:
match_text = best_match.inner_text() or best_match.get_attribute("value") or best_match.get_attribute("placeholder")
logger.info(f"Element mit Ähnlichkeit {best_similarity:.2f} gefunden: '{match_text}'")
except:
logger.info(f"Element mit Ähnlichkeit {best_similarity:.2f} gefunden")
return best_match
logger.debug(f"Kein passendes Element für die angegebenen Muster gefunden: {patterns}")
return None
except Exception as e:
logger.error(f"Fehler beim Suchen nach ähnlichem Element: {e}")
return None
def find_element_by_text(page, text, exact=False, selector="*", timeout=5000) -> Optional[Any]:
"""
Findet ein Element, das den angegebenen Text enthält oder ihm ähnlich ist.
Args:
page: Playwright Page-Objekt
text: Zu suchender Text
exact: Ob exakte Übereinstimmung erforderlich ist
selector: CSS-Selektor zum Einschränken der Suche
timeout: Timeout in Millisekunden
Returns:
Gefundenes Element oder None
"""
try:
if exact:
# Bei exakter Suche XPath verwenden
xpath = f"//{selector}[contains(text(), '{text}') or contains(@value, '{text}') or contains(@placeholder, '{text}')]"
return page.wait_for_selector(xpath, timeout=timeout)
else:
# Bei Ähnlichkeitssuche alle passenden Elemente finden
similarity = TextSimilarity(0.8) # 80% Schwellenwert
# Warten auf DOM-Bereitschaft
page.wait_for_load_state("domcontentloaded", timeout=timeout)
# Alle Elemente mit dem angegebenen Selektor finden
elements = page.query_selector_all(selector)
for element in elements:
# Verschiedene Textattribute prüfen
element_text = element.inner_text()
if not element_text:
element_text = element.get_attribute("value") or ""
if not element_text:
element_text = element.get_attribute("placeholder") or ""
# Ähnlichkeit prüfen
if similarity.is_similar(text, element_text):
return element
return None
except Exception as e:
logger.error(f"Fehler beim Suchen nach Element mit Text '{text}': {e}")
return None
def click_fuzzy_button(page, button_text, threshold=0.7, timeout=5000) -> bool:
"""
Klickt auf einen Button basierend auf Textähnlichkeit.
Args:
page: Playwright Page-Objekt
button_text: Text oder Textmuster des Buttons
threshold: Ähnlichkeitsschwellenwert
timeout: Timeout in Millisekunden
Returns:
True bei Erfolg, False bei Fehler
"""
try:
# Versuche, das Element zu finden
button = fuzzy_find_element(page, button_text, selector_type="button",
threshold=threshold, wait_time=timeout)
if button:
# Scrolle zum Button und klicke
button.scroll_into_view_if_needed()
button.click()
logger.info(f"Auf Button mit Text ähnlich zu '{button_text}' geklickt")
return True
else:
logger.warning(f"Kein Button mit Text ähnlich zu '{button_text}' gefunden")
return False
except Exception as e:
logger.error(f"Fehler beim Klicken auf Button mit Text '{button_text}': {e}")
return False