558 Zeilen
20 KiB
Python
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 |