""" 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