Initial commit
Dieser Commit ist enthalten in:
558
utils/text_similarity.py
Normale Datei
558
utils/text_similarity.py
Normale Datei
@ -0,0 +1,558 @@
|
||||
"""
|
||||
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
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren