diff --git a/brancheneinstufung.py b/brancheneinstufung.py index 7c68740f..389dc73b 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -221,16 +221,42 @@ ALLOWED_TARGET_BRANCHES = [] # Liste der erlaubten Kurzformen # ============================================================================== # Logger Setup (Wird in main() finalisiert) -# Erhalten Sie eine Logger-Instanz für dieses Modul +# Erhalten Sie eine Logger-Instanz für dieses Modul. Der Root-Logger wird in main() konfiguriert. logger = logging.getLogger(__name__) +# Zusätzliche Imports, die von globalen Helfern benötigt werden (einige sind bereits am Anfang) +import random # Für Jitter im Backoff +import time # Für sleep +# logging ist bereits importiert +import requests # Für requests.exceptions +import gspread # Für gspread.exceptions +import openai # Für openai.error +import wikipedia # Für wikipedia.exceptions +# traceback ist bereits importiert +# re ist bereits importiert +# csv ist bereits importiert +# json ist bereits importiert +# pickle ist bereits importiert +# datetime ist bereits importiert +# urllib.parse (unquote) ist bereits importiert +# difflib (SequenceMatcher) ist bereits importiert +# unicodedata ist bereits importiert +# pandas, numpy sind bereits importiert +# concurrent.futures, threading sind bereits importiert +# gender_guesser ist bereits importiert +# tiktoken ist bereits importiert + + +# Logger für den Retry Decorator selbst +decorator_logger = logging.getLogger(__name__ + ".Retry") + # --- Retry Decorator --- -# Übernommen aus Ihrem Code (Teil 1) +# KORRIGIERTE Version def retry_on_failure(func): """ Decorator, der eine Funktion bei bestimmten Fehlern mehrmals wiederholt. - Implementiert exponentiellen Backoff. + Implementiert exponentiellen Backoff mit Jitter. """ def wrapper(*args, **kwargs): func_name = func.__name__ @@ -240,71 +266,95 @@ def retry_on_failure(func): effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name # Basiswartezeit und maximale Anzahl Versuche aus Config holen - max_retries = getattr(Config, 'MAX_RETRIES', 3) + max_retries_config = getattr(Config, 'MAX_RETRIES', 3) # Anzahl der Versuche (nicht Wiederholungen nach dem ersten Fehler) base_delay = getattr(Config, 'RETRY_DELAY', 5) - for attempt in range(max_retries): + # Wenn max_retries_config 0 oder weniger ist, einfach einmal ausführen + if max_retries_config <= 0: + try: + return func(*args, **kwargs) + except Exception as e: + # Fehler loggen und weitergeben, wenn keine Retries konfiguriert sind + decorator_logger.error(f"FEHLER bei '{effective_func_name}' (keine Retries konfiguriert). {type(e).__name__} - {str(e)[:150]}...") + # Log traceback für unerwartete Fehler + if not isinstance(e, (requests.exceptions.RequestException, gspread.exceptions.APIError, openai.error.OpenAIError, wikipedia.exceptions.WikipediaException)): + decorator_logger.exception("Details zum Fehler:") + raise e # Re-raise the exception + + + # --- Retry logic for max_retries_config > 0 --- + # Die Schleife läuft max_retries_config mal. + for attempt in range(max_retries_config): try: - # debug_print(f"Versuch {attempt + 1}/{max_retries} für '{effective_func_name}'...") # Zu viel Lärm - return func(*args, **kwargs) + # Logge jeden Versuch, außer den ersten (optional, um Log-Lärm zu reduzieren) + if attempt > 0: + decorator_logger.warning(f"Wiederhole Versuch {attempt + 1}/{max_retries_config} für '{effective_func_name}'...") + + return func(*args, **kwargs) # Call the original function + + # Spezifische Exceptions, die ein Retry rechtfertigen except (requests.exceptions.RequestException, gspread.exceptions.APIError, openai.error.OpenAIError, wikipedia.exceptions.WikipediaException) as e: - # Diese spezifischen Fehler werden für einen Retry behandelt error_msg = str(e) error_type = type(e).__name__ - # Logge den Fehler auf WARN/ERROR Level - if isinstance(e, gspread.exceptions.APIError) and e.response.status_code == 429: - logger.warning(f"🚦 RATE LIMIT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") - elif isinstance(e, requests.exceptions.Timeout): - logger.warning(f"⏰ TIMEOUT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") - elif isinstance(e, requests.exceptions.RequestException): - logger.warning(f"🌐 NETZWERKFEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") - elif isinstance(e, openai.error.OpenAIError): - logger.warning(f"🤖 OPENAI FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") - elif isinstance(e, wikipedia.exceptions.WikipediaException): - logger.warning(f"📚 WIKIPEDIA FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") - else: # Andere gefangene Exceptions (sollten seltener sein, aber sicherheitshalber) - logger.warning(f"⚠️ BEHANDELTER FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") + if attempt < max_retries_config - 1: # Wenn nicht der letzte Versuch + wait_time = base_delay * (2 ** attempt) + random.uniform(0, 1) # Exponentieller Backoff mit Jitter + # Loggen Sie den spezifischen Fehler und die Wartezeit + if isinstance(e, gspread.exceptions.APIError) and hasattr(e, 'response') and e.response is not None and e.response.status_code == 429: + decorator_logger.warning(f"🚦 RATE LIMIT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") + elif isinstance(e, requests.exceptions.Timeout): + decorator_logger.warning(f"⏰ TIMEOUT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") + elif isinstance(e, requests.exceptions.RequestException): + decorator_logger.warning(f"🌐 NETZWERKFEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") + elif isinstance(e, openai.error.OpenAIError): + decorator_logger.warning(f"🤖 OPENAI FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") + elif isinstance(e, wikipedia.exceptions.WikipediaException): + decorator_logger.warning(f"📚 WIKIPEDIA FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") + else: # Andere spezifisch behandelte Exceptions + decorator_logger.warning(f"⚠️ BEHANDELTER FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") + + time.sleep(wait_time) # Warte vor dem nächsten Versuch + else: # Letzter Versuch fehlgeschlagen + decorator_logger.error(f"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}' nach {max_retries_config} Versuchen.") + # Log traceback für erwartete Fehler beim endgültigen Scheitern + # Log traceback nur bei unerwarteten Fehlern (nicht den, der zum Retry führte) + # decorator_logger.exception("Details zum endgültigen Fehler:") # Kann zu viel Lärm machen + raise e # Leite die ursprüngliche Exception weiter - if attempt < max_retries - 1: - # Wartezeit berechnen: Basis * (2^attempt) - Fügen Sie etwas Zufälligkeit hinzu, um Thundering Herd zu vermeiden - wait_time = base_delay * (2 ** attempt) + random.uniform(0, 1) # random importieren! - logger.info(f"Warte {wait_time:.2f}s vor Versuch {attempt+2}...") - time.sleep(wait_time) - else: - # Letzter Versuch fehlgeschlagen - logger.error(f"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}' nach {max_retries} Versuchen.") - # Den ursprünglichen Fehler erneut werfen, damit die aufrufende Logik ihn behandeln kann - # (z.B. einen Fehlerwert in das Sheet schreiben) - raise e except Exception as e: - # Nicht spezifizierte Fehler (z.B. Programmierfehler, unerwartete Ausnahmen) - # Diese sollten NICHT wiederholt werden, da sie wahrscheinlich strukturell sind. - logger.critical(f"💥 UNERWARTETER FEHLER ({type(e).__name__}) bei '{effective_func_name}'. KEIN RETRY VERSUCHT.") - logger.exception("Details zum unerwarteten Fehler:") # Logge den vollständigen Traceback - raise e # Fehler sofort weitergeben + # Fangen Sie sofort alle anderen unerwarteten Exceptions ab (z. B. Programmierfehler) + # Diese sollten nicht wiederholt werden. + decorator_logger.critical(f"💥 UNERWARTETER FEHLER ({type(e).__name__}) bei '{effective_func_name}'. KEIN RETRY VERSUCHT.") + decorator_logger.exception("Details zum unerwarteten Fehler:") # Loggen Sie den vollständigen Traceback + raise e # Leiten Sie die Exception sofort weiter - # Dieser Teil sollte nur erreicht werden, wenn die Retry-Schleife beendet ist, - # was nur passiert, wenn max_retries > 0 war und alle Versuche fehlschlugen (durch re-raise e am Ende). - # Oder wenn max_retries == 0 war. - # Wir können hier eine Standard-Rückgabe für den Fehlerfall einbauen, falls der Aufrufer dies erwartet. - # Das hängt davon ab, wie die aufgerufenen Funktionen Fehler signalisieren (None, "k.A.", Exception). - # Da wir am Ende eines fehlerhaften Retry-Zyklus ein 'raise e' haben, wird dieser Punkt normalerweise NICHT erreicht, - # wenn ein Fehler auftritt. - # Wenn kein Fehler auftritt, wird der Wert vom 'return func(*args, **kwargs)' zurückgegeben. - # Lassen wir es so, dass Exceptions durchgereicht werden, das ist oft sauberer. - # Wenn die aufrufende Funktion None erwartet bei Fehler, muss sie den raised Exception fangen und None zurückgeben. - pass # Sollte nicht erreicht werden + # Dieser Teil sollte theoretisch nicht erreicht werden, wenn max_retries_config > 0 + # und eine Exception immer zu einer raise e Anweisung führt. + # Wenn die Schleife endet, ohne zurückzukehren oder eine Exception zu werfen, + # deutet dies auf einen Logikfehler im Decorator hin. + # Als Schutzmechanismus werfen wir hier eine RuntimeError, falls dieser Punkt doch erreicht wird. + # Oder geben None zurück, falls das erwartete Verhalten bei endgültigem Fehler None ist. + # Angesichts der Tatsache, dass die aufgerufenen Funktionen oft None bei Fehler zurückgeben, + # geben wir hier None zurück, anstatt eine RuntimeError zu werfen. + # Dies erfordert, dass die aufgerufene Funktion im Falle eines Fehlers IMMER eine Exception wirft, + # die vom Decorator gefangen und (beim letzten Versuch) weitergeleitet wird. + # Wenn die dekorierte Funktion selbst interne Fehler fängt und None zurückgibt, + # wird der Decorator dies als erfolgreichen Durchlauf interpretieren und None zurückgeben. + # Das ist das erwartete Verhalten. + # Entfernen Sie die RuntimeError und lassen Sie die Funktion implizit None zurückgeben, + # wenn der try/except/raise Block nicht erreicht wurde. + pass # Dies sollte nicht erreicht werden, wenn eine Exception geworfen wird. + # Die wrapper Funktion muss am Ende zurückgegeben werden + return wrapper -# Importieren Sie das 'random' Modul, da es im retry_on_failure Decorator verwendet wird. -import random # --- Token Count Funktion --- # Übernommen aus Ihrem Code (Teil 5), leicht angepasst für Logger. -@retry_on_failure # Retry hier nicht sinnvoll, da es lokale Berechnung ist. Entferne Decorator. +# Der retry_on_failure Decorator ist hier nicht sinnvoll, da es eine lokale Berechnung ist. def token_count(text, model=None): """Zählt Tokens via tiktoken oder schätzt über Leerzeichen.""" + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist if not text or not isinstance(text, str): return 0 current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo') @@ -321,42 +371,42 @@ def token_count(text, model=None): except Exception as e: logger.debug(f"Fehler beim Token-Counting mit tiktoken für Modell '{current_model}': {e} - Fallback zur Schätzung.") # Fallback zur Schätzung - return len(text.split()) + return len(str(text).split()) # Sicherstellen, dass text ein String ist else: # Fallback Schätzung - return len(text.split()) + return len(str(text).split()) # Sicherstellen, dass text ein String ist # --- Logging Helpers --- # Übernommen aus Ihrem Code (Teil 3), leicht angepasst für Standard-Logger. -LOG_FILE = None # Wird in main() gesetzt +# LOG_FILE ist global definiert und wird in main() gesetzt +LOG_FILE = None def create_log_filename(mode): """Erstellt einen zeitgestempelten Logdateinamen im LOG_DIR.""" - # Der Logger ist hier möglicherweise noch nicht voll konfiguriert. Verwenden Sie print. - if not os.path.exists(LOG_DIR): + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist (print am Anfang von main) + log_dir_path = LOG_DIR # Nutzt die globale Konstante + + if not os.path.exists(log_dir_path): try: - os.makedirs(LOG_DIR, exist_ok=True) # exist_ok=True verhindert Fehler, wenn Dir existiert - print(f"Log-Verzeichnis '{LOG_DIR}' erstellt.") + os.makedirs(log_dir_path, exist_ok=True) # exist_ok=True verhindert Fehler, wenn Dir existiert + logger.info(f"Log-Verzeichnis '{log_dir_path}' erstellt.") except Exception as e: - print(f"FEHLER: Konnte Log-Verzeichnis '{LOG_DIR}' nicht erstellen: {e}") + logger.error(f"FEHLER: Konnte Log-Verzeichnis '{log_dir_path}' nicht erstellen: {e}") # Versuche, die Datei im aktuellen Verzeichnis zu erstellen, wenn LOG_DIR fehlschlägt - LOG_DIR_FALLBACK = "." - print(f"Versuche, Logdatei im aktuellen Verzeichnis '{LOG_DIR_FALLBACK}' zu erstellen.") - try: - now = datetime.now().strftime("%d-%m-%Y_%H-%M") - ver_short = getattr(Config, 'VERSION', 'unknown').replace(".", "") - return os.path.join(LOG_DIR_FALLBACK, f"{now}_{ver_short}_Modus{mode}.txt") - except Exception as e_fallback: - print(f"FEHLER: Konnte Logdateinamen auch im Fallback-Verzeichnis nicht erstellen: {e_fallback}") - return None # Signalisiert Fehler + log_dir_path = "." # Fallback Verzeichnis + logger.warning(f"Versuche, Logdatei im aktuellen Verzeichnis '{log_dir_path}' zu erstellen.") + try: + now = datetime.now().strftime("%d-%m-%Y_%H-%M") + # Sicherstellen, dass Config.VERSION verfügbar ist, Fallback falls nicht + ver_short = getattr(Config, 'VERSION', 'unknown').replace(".", "") + filename = f"{now}_{ver_short}_Modus{mode}.txt" + return os.path.join(log_dir_path, filename) + except Exception as e_fallback: + logger.error(f"FEHLER: Konnte Logdateinamen auch im Fallback-Verzeichnis '{log_dir_path}' nicht erstellen: {e_fallback}") + return None # Signalisiert Fehler - now = datetime.now().strftime("%d-%m-%Y_%H-%M") - # Sicherstellen, dass Config.VERSION verfügbar ist, Fallback falls nicht - ver_short = getattr(Config, 'VERSION', 'unknown').replace(".", "") - filename = f"{now}_{ver_short}_Modus{mode}.txt" - return os.path.join(LOG_DIR, filename) # debug_print ist nicht mehr notwendig, da wir das Standard-Logging nutzen. # Alle bisherigen Aufrufe von debug_print werden durch logger.debug, logger.info, logger.warning, logger.error, logger.critical ersetzt. @@ -383,7 +433,9 @@ def simple_normalize_url(url): domain_part = domain_part.lower() # Kleinschreibung # Optional: "www." entfernen if domain_part.startswith("www."): domain_part = domain_part[4:] - return domain_part if '.' in domain_part and domain_part.split('.')[-1].isalpha() else "k.A." # Einfache TLD Prüfung + # Einfache Prüfung auf mindestens einen Punkt (Basic TLD check) + # Prüfen Sie auch auf leere domain_part nach Bearbeitung + return domain_part if domain_part and '.' in domain_part and domain_part.split('.')[-1].isalpha() else "k.A." except Exception as e: logger.error(f"Fehler bei URL-Normalisierung für '{url[:100]}...': {e}") return "k.A." @@ -391,6 +443,7 @@ def simple_normalize_url(url): def normalize_string(s): """Normalisiert Umlaute und Sonderzeichen nach einer definierten Liste.""" if not s or not isinstance(s, str): return "" + # Ersetzungen wie in Teil 3 replacements = { 'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Å': 'A', 'Æ': 'AE', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'æ': 'ae', 'Ç': 'C', 'ç': 'c', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'Ñ': 'N', 'ñ': 'n', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ø': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'Œ': 'OE', 'œ': 'oe', 'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z', 'Ý': 'Y', 'ý': 'y', 'ÿ': 'y', 'Đ': 'D', 'đ': 'd', 'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', 'ł': 'l', 'Ł': 'L', 'ğ': 'g', 'Ğ': 'G', 'ş': 's', 'Ş': 'S', 'ă': 'a', 'Ă': 'A', 'ı': 'i', 'İ': 'I', 'ň': 'n', 'Ň': 'N', 'ř': 'r', 'Ř': 'R', 'ő': 'o', 'Ő': 'O', 'ű': 'u', 'Ű': 'U', 'ț': 't', 'Ț': 'T', 'ș': 's', 'Ș': 'S' } # unicodedata Normalisierung zuerst try: s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii') @@ -418,30 +471,40 @@ def normalize_company_name(name): """Entfernt gängige Rechtsformzusätze etc. für Vergleiche.""" if not name: return "" name = clean_text(name) - forms = [ r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschränkter haftung', r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschränkt', r'ag', r'a\.g\.', r'aktiengesellschaft', r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft', r'kg', r'k\.g\.', r'kommanditgesellschaft', r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?', r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?', r'e\.k\.', r'e\.kfm\.', r'e\.kfr\.', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann', r'ltd\.?', r'limited', r'ltd\s*&\s*co\.?\s*kg', r's\.?a\.?r\.?l\.?', r'sàrl', r'sagl', r's\.?a\.?', r'société anonyme', r'sociedad anónima', r's\.?p\.?a\.?', r'società per azioni', r'b\.?v\.?', r'besloten vennootschap', r'n\.?v\.?', r'naamloze vennootschap', r'plc\.?', r'public limited company', r'inc\.?', r'incorporated', r'corp\.?', r'corporation', r'llc\.?', r'limited liability company', r'kgaa', r'kommanditgesellschaft auf aktien', r'se', r'societas europaea', r'e\.?g\.?', r'eingetragene genossenschaft', r'genossenschaft', r'genmbh', r'e\.?v\.?', r'eingetragener verein', r'verein', r'stiftung', r'ggmbh', r'gemeinnützige gmbh', r'gug', r'partg', r'partnerschaftsgesellschaft', r'partgmbb', r'og', r'o\.g\.', r'offene gesellschaft', r'e\.u\.', r'eingetragenes unternehmen', r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach bürgerlichem recht', r'kollektivgesellschaft', r'einzelfirma', r'gruppe', r'holding', r'international', r'systeme', r'technik', r'logistik', r'solutions', r'services', r'management', r'consulting', r'produktion', r'vertrieb', r'entwicklung', r'maschinenbau', r'anlagenbau' ] - pattern = r'\b(' + '|'.join(forms) + r')\b' + forms = [ r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschränkter haftung', r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschränkt', r'ag', r'a\.g\.', r'aktiengesellschaft', r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft', r'kg', r'k\.g\.', r'kommanditgesellschaft', r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?', r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?', r'e\.k\.', r'e\.kfm\.', r'e\.kfr\.', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann', r'ltd\.?', r'limited', r'ltd\s*&\s*co\.?\s*kg', r's\.?a\.?r\.?l\.?', r'sàrl', r'sagl', r's\.?a\.?', r'société anonyme', r'sociedad anónima', r's\.?p\.?a\.?', r'società per azioni', r'b\.?v\.?', r'besloten vennootschap', r'n\.?v\.?', r'naamloze vennootschap', r'plc\.?', r'public limited company', 'inc', 'incorporated', r'corp\.?', 'corporation', 'llc', 'limited liability company', r'kgaa', r'kommanditgesellschaft auf aktien', 'se', 'societas europaea', r'e\.?g\.?', r'eingetragene genossenschaft', 'genossenschaft', 'genmbh', r'e\.?v\.?', r'eingetragener verein', 'verein', 'stiftung', 'ggmbh', r'gemeinnützige gmbh', 'gug', 'partg', 'partnerschaftsgesellschaft', 'partgmbb', 'og', r'o\.g\.', 'offene gesellschaft', r'e\.u\.', 'eingetragenes unternehmen', r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach bürgerlichem recht', 'kollektivgesellschaft', 'einzelfirma', 'gruppe', 'holding', 'international', 'systeme', 'technik', 'logistik', 'solutions', 'services', 'management', 'consulting', 'produktion', 'vertrieb', 'entwicklung', 'maschinenbau', 'anlagenbau' + ] + # Pattern für ganze Wörter (case-insensitive) + # Fügen Sie \b hinzu, um sicherzustellen, dass ganze Wörter gematcht werden (z.B. nicht "ag" in "manage") + # Bereinigen Sie die Formen vor dem Join (z.B. re.escape für Sonderzeichen in den Formen) + forms_escaped = [re.escape(form) for form in forms] + pattern = r'\b(?:' + '|'.join(forms_escaped) + r')\b' # ?: für non-capturing group normalized = re.sub(pattern, '', name, flags=re.IGNORECASE) + + # Interpunktion entfernen/ersetzen (außer evtl. &) normalized = re.sub(r'[.,;:]', '', normalized) - normalized = re.sub(r'[\-–/]', ' ', normalized) - normalized = re.sub(r'\s+', ' ', normalized).strip() + normalized = re.sub(r'[\-–/]', ' ', normalized) # Bindestriche etc. durch Leerzeichen ersetzen + normalized = re.sub(r'\s+', ' ', normalized).strip() # Multiple Leerzeichen reduzieren + return normalized.lower() def fuzzy_similarity(str1, str2): """Berechnet Ähnlichkeit zwischen 0 und 1 (case-insensitive).""" if not str1 or not str2: return 0.0 + # Sicherstellen, dass beide Inputs Strings sind return SequenceMatcher(None, str(str1).lower(), str(str2).lower()).ratio() + # --- Numerische Extraktion --- -# Übernommen aus Ihrem Code (Teil 4), leicht angepasst für Logger. +# Übernommen aus Ihrem Code (Teil 4 & Teil 2), leicht angepasst für Logger und Konsistenz. def extract_numeric_value(raw_value, is_umsatz=False): """ Extrahiert und normalisiert Zahlenwerte (Umsatz in Mio, Mitarbeiter). Berücksichtigt Tausendertrenner (Punkt, Apostroph), Dezimaltrenner (Komma), Einheiten (Tsd, Mio, Mrd) - und gängige Präfixe/Suffixe. + und gängige Präfixe/Suffixe. Gibt "k.A." zurück, wenn nicht extrahierbar oder <= 0. """ if not raw_value: return "k.A." raw_value_str = str(raw_value).strip() - if not raw_value_str or raw_value_str.lower() in ['k.a.', 'n/a', '-', '0']: + if not raw_value_str or raw_value_str.lower() in ['k.a.', 'n/a', '-']: return "k.A." # 0 ist hier wie k.A. # Bereinigungsschritte wie in clean_text und vorheriger Implementierung @@ -462,7 +525,7 @@ def extract_numeric_value(raw_value, is_umsatz=False): match = re.search(r'([\d.]+)', processed_value_final) if not match: - logger.debug(f"Keine numerischen Zeichen gefunden nach Bereinigung von: '{raw_value_str}'") + logger.debug(f"extract_numeric_value: Keine numerischen Zeichen gefunden nach Bereinigung von: '{raw_value_str}'") return "k.A." num_str = match.group(1) @@ -491,36 +554,39 @@ def extract_numeric_value(raw_value, is_umsatz=False): num = num * multiplier # Konvertiere zu Zielformat und runde ggf. + # Rückgabe als String, wie im Sheet erwartet if is_umsatz: # Umsatz wird in Millionen € gespeichert (gerundet auf ganze Mio) + # Rückgabe als String umsatz_mio = round(num / 1000000.0) return str(int(umsatz_mio)) if umsatz_mio > 0 else "k.A." # Nur positive Ergebnisse else: # Mitarbeiterzahl wird als ganze Zahl gespeichert (gerundet) + # Rückgabe als String mitarbeiter_int = round(num) return str(int(mitarbeiter_int)) if mitarbeiter_int > 0 else "k.A." # Nur positive Ergebnisse # --- Numerische Extraktion für FILTERLOGIK (gibt 0 statt k.A. zurück) --- -# Übernommen aus Ihrem Code (Teil 2), leicht angepasst für Logger. +# Übernommen aus Ihrem Code (Teil 2), leicht angepasst für Logger und Konsistenz mit extract_numeric_value. def get_numeric_filter_value(value_str, is_umsatz=False): """ Extrahiert und normalisiert Zahlenwerte für die Filterlogik (Umsatz in Mio, Mitarbeiter int). - Gibt 0 zurück, wenn der Wert leer, k.A., nicht numerisch ist, oder 0 ergibt. + Gibt 0.0 (für Umsatz) oder 0 (für Mitarbeiter) zurück, wenn der Wert leer, k.A., nicht numerisch ist, oder 0 ergibt. Beachtet Einheiten (Tsd, Mio, Mrd) für Umsatz. """ if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': - return 0 # Leer oder k.A. -> 0 + return 0.0 if is_umsatz else 0 # Leer oder k.A. -> 0 raw_value_str = str(value_str).strip() if raw_value_str.lower() in ['k.a.', 'n/a', '-']: - return 0 + return 0.0 if is_umsatz else 0 try: processed_value = clean_text(raw_value_str) - if processed_value == "k.A.": return 0 + if processed_value == "k.A.": return 0.0 if is_umsatz else 0 - processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value) + processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|under|mehr als|weniger als|bis zu)\s+', '', processed_value) processed_value = re.sub(r'[€$£¥]', '', processed_value).strip() processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip() processed_value_no_thousands = processed_value.replace('.', '').replace("'", "") @@ -528,63 +594,71 @@ def get_numeric_filter_value(value_str, is_umsatz=False): match = re.search(r'([\d.]+)', processed_value_final) if not match: - # logger.debug(f"get_numeric_filter_value: Keine numerischen Zeichen gefunden in '{processed_value_final}'") # Zu viel Lärm - return 0 + return 0.0 if is_umsatz else 0 num_str = match.group(1) - if not num_str or num_str == '.': return 0 + if not num_str or num_str == '.' or num_str.endswith('.'): return 0.0 if is_umsatz else 0 num = float(num_str) - # --- Einheiten-Skalierung --- + # --- Einheiten-Skalierung basierend auf ORIGINALSTRING --- original_lower = raw_value_str.lower() - # Diese Logik muss den Vergleich mit dem *Schwellenwert* in Mio/Integer berücksichtigen. - # Der Schwellenwert für Umsatz (min_umsatz) ist in Mio. - # Der Schwellenwert für MA (min_employees) ist eine ganze Zahl. - # Ziel: Den Wert aus dem Sheet in die Einheit des Schwellenwerts konvertieren. + multiplier = 1.0 - if is_umsatz: # Umsatz (Schwellenwert in Mio) - if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): - num = num * 1000.0 # Konvertiere von Mrd zu Mio - elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): - num = num / 1000.0 # Konvertiere von Tsd zu Mio - # Wenn "Mio" oder keine Einheit, nehme num direkt (wird als Mio interpretiert) + if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): + multiplier = 1000000000.0 + elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill.\s*\b', original_lower): + multiplier = 1000000.0 + elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): + multiplier = 1000.0 - else: # Mitarbeiterzahl (Schwellenwert ist Integer) - if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): - num = num * 1000000000.0 # Konvertiere von Mrd zu Integer - elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill.\s*\b', original_lower): - num = num * 1000000.0 # Konvertiere von Mio zu Integer - elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): - num = num * 1000.0 # Konvertiere von Tsd zu Integer - # Wenn keine Einheit, nehme num direkt (wird als Integer interpretiert) + num = num * multiplier # Das Ergebnis muss 0 oder positiv sein für die Filterlogik - return num if num > 0 else 0 + result_num = num if num > 0 else 0 # Werte <= 0 zählen nicht + + if is_umsatz: + # Rückgabe als Wert in Millionen (Float) + return result_num / 1000000.0 + else: # Mitarbeiterzahl + # Rückgabe als ganze Zahl + return round(result_num) except Exception as e: logger.debug(f"Fehler in get_numeric_filter_value für Wert '{raw_value_str[:50]}...': {e}") - return 0 + return 0.0 if is_umsatz else 0 # --- Gender und Email Helpers --- # Übernommen aus Ihrem Code (Teil 4), leicht angepasst für Logger. # Annahme: gender_guesser ist installiert -gender_detector = gender.Detector() # Instanz außerhalb der Funktion erstellen +# Initialisieren Sie den Detector einmal global +try: + gender_detector = gender.Detector() + logger.debug("gender_guesser.Detector initialisiert.") +except ImportError: + gender_detector = None + logger.warning("gender_guesser Bibliothek nicht gefunden. Geschlechtserkennung deaktiviert.") +except Exception as e: + gender_detector = None + logger.error(f"Fehler bei Initialisierung von gender_guesser: {e}. Geschlechtserkennung deaktiviert.") + def get_gender(firstname): """Ermittelt Geschlecht via gender-guesser und Fallback Genderize API.""" if not firstname or not isinstance(firstname, str): return "unknown" - firstname_clean = firstname.strip().split(" ")[0] + firstname_clean = firstname.strip().split(" ")[0] # Nur den ersten Teil des Vornamens if not firstname_clean: return "unknown" # 1. Versuch: gender-guesser (nutzt globale Instanz) - try: - result_gg = gender_detector.get_gender(firstname_clean) - # logger.debug(f"GenderGuesser für '{firstname_clean}': {result_gg}") # Zu viel Lärm - except Exception as e_gg: - logger.warning(f"Fehler bei gender-guesser für '{firstname_clean}': {e_gg}") - result_gg = "unknown" + result_gg = "unknown" + if gender_detector: + try: + result_gg = gender_detector.get_gender(firstname_clean) + # logger.debug(f"GenderGuesser für '{firstname_clean}': {result_gg}") # Zu viel Lärm + except Exception as e_gg: + logger.warning(f"Fehler bei gender-guesser für '{firstname_clean}': {e_gg}") + result_gg = "unknown" # Fallback bei Fehler # 2. Fallback: Genderize API (nur wenn gender-guesser unsicher ist) if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]: @@ -593,28 +667,35 @@ def get_gender(firstname): # logger.debug("Genderize API-Schlüssel nicht verfügbar, Fallback nicht möglich.") # Zu viel Lärm return result_gg if result_gg.startswith("mostly_") else "unknown" # Gib bestenfalls mostly zurück - params = {"name": firstname_clean, "apikey": genderize_key, "country_id": "DE"} - try: - # logger.debug(f"Genderize API-Anfrage für '{firstname_clean}'...") # Zu viel Lärm - response = requests.get("https://api.genderize.io", params=params, timeout=5) - response.raise_for_status() - data = response.json() - # logger.debug(f" -> Genderize Antwort: {data}") # Zu viel Lärm + # API Call nutzt den retry_on_failure Decorator + @retry_on_failure + def call_genderize(name, api_key): + params = {"name": name, "apikey": api_key, "country_id": "DE"} # DE als Standardland + # logger.debug(f"Genderize API-Anfrage für '{name}'...") # Zu viel Lärm + response = requests.get("https://api.genderize.io", params=params, timeout=5) # Kurzer Timeout + response.raise_for_status() # Wirft HTTPError für schlechte Antworten + data = response.json() + # logger.debug(f" -> Genderize Antwort: {data}") # Zu viel Lärm + return data - api_gender = data.get("gender") - probability = data.get("probability", 0) - if api_gender and probability is not None and probability > 0.7: - logger.debug(f" -> Übernehme Genderize Ergebnis '{api_gender}' (Prob: {probability}) für '{firstname_clean}'") + try: + genderize_data = call_genderize(firstname_clean, genderize_key) + + api_gender = genderize_data.get("gender") + probability = genderize_data.get("probability", 0) + count = genderize_data.get("count", 0) # Anzahl der Datenpunkte für diesen Namen + + # Nur bei ausreichender Sicherheit und wenn Genderize ein Ergebnis liefert + # Prüfen Sie auch die Anzahl der Datenpunkte (count > 0) + if api_gender and probability is not None and probability > 0.7 and count > 0: + logger.debug(f" -> Übernehme Genderize Ergebnis '{api_gender}' (Prob: {probability}, Count: {count}) für '{firstname_clean}'") return api_gender else: # logger.debug(f" -> Genderize unsicher/kein Ergebnis. Nutze Fallback: '{result_gg}'") # Zu viel Lärm return result_gg if result_gg.startswith("mostly_") else "unknown" - except requests.exceptions.RequestException as e: - logger.error(f"Fehler bei der Genderize API-Anfrage für '{firstname_clean}': {e}") - return result_gg if result_gg.startswith("mostly_") else "unknown" - except Exception as e: - logger.error(f"Allgemeiner Fehler bei Genderize für '{firstname_clean}': {e}") + except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut + logger.error(f"FEHLER bei der Genderize API-Anfrage für '{firstname_clean}': {e}") return result_gg if result_gg.startswith("mostly_") else "unknown" else: return result_gg @@ -635,8 +716,8 @@ def get_email_address(firstname, lastname, website): normalized_first = re.sub(r'\s+', '-', normalized_first) normalized_last = re.sub(r'\s+', '-', normalized_last) - # Erlauben: alphanumerische Zeichen, Bindestrich, Punkt (nur intern, nicht am Anfang/Ende) - # Hier erlauben wir erstmal nur alphanumerische und Bindestrich nach Normalisierung + # Erlauben: alphanumerische Zeichen, Bindestrich + # Entfernen Sie alle Zeichen, die NICHT alphanumerisch oder Bindestrich sind normalized_first = re.sub(r'[^\w\-]+', '', normalized_first) normalized_last = re.sub(r'[^\w\-]+', '', normalized_last) @@ -653,6 +734,11 @@ def get_email_address(firstname, lastname, website): # --- Schema Loading (Ziel-Branchenschema) --- # Übernommen aus Ihrem Code (Teil 4), leicht angepasst für Logger. # Annahmen: BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES sind globale Variablen +BRANCH_MAPPING = {} # Wird derzeit nicht verwendet, aber beibehalten +TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar." +ALLOWED_TARGET_BRANCHES = [] # Liste der erlaubten Kurzformen + + def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE): """Lädt Liste erlaubter Ziel-Branchen (Kurzformen) aus Spalte A der CSV.""" global BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES @@ -661,17 +747,13 @@ def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE): BRANCH_MAPPING = {} # Zurücksetzen allowed_branches_set = set() - # Verwenden Sie logger, da die Logging-Konfiguration in main() erfolgt logger.info(f"Lade Ziel-Schema (Kurzformen) aus '{csv_filepath}' Spalte A...") line_count = 0 try: # Verwenden Sie 'utf-8-sig' für Dateien mit BOM with open(csv_filepath, "r", encoding="utf-8-sig") as f: reader = csv.reader(f) - # Optional: Header überspringen, wenn die erste Zeile kein valider Branch ist - # In vielen CSVs ist die erste Zeile der Header. Wir können diese heuristisch überspringen. - # Oder man macht eine explizite Konfiguration. - # Einfachster Ansatz: Erste Zeile ist Header, ignoriere sie. + # Versuche, die erste Zeile als Header zu überspringen try: header_row = next(reader) # logger.debug(f"Überspringe Header-Zeile: {header_row}") # Zu viel Lärm @@ -726,7 +808,736 @@ def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE): # map_external_branch ist in dieser Struktur nicht mehr notwendig, # da die Branchenevaluation über ChatGPT (evaluate_branche_chatgpt) # direkt gegen ALLOWED_TARGET_BRANCHES validiert. -# Entferne die Funktion map_external_branch. + + +# --- OpenAI / CHATGPT FUNCTIONS --- +# Übernommen aus Ihrem Code (Teil 7), angepasst als globale Funktionen. + +@retry_on_failure +def call_openai_chat(prompt, temperature=0.3, model=None): + """Zentrale Funktion für OpenAI Chat API Aufrufe.""" + if not Config.API_KEYS.get('openai'): + logger.error("Fehler: OpenAI API Key nicht konfiguriert.") + # Anstatt None zurückzugeben, werfen Sie eine Exception, damit retry_on_failure dies behandelt (oder nicht, je nach Config) + raise openai.error.AuthenticationError("OpenAI API Key nicht konfiguriert.") + + if not prompt: + logger.error("Fehler: Leerer Prompt für OpenAI.") + # Werfen Sie eine Value Error Exception + raise ValueError("Leerer Prompt für OpenAI.") + + current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo') + + try: + # Token zählen vor dem Senden (optional, gut für Debugging/Monitoring) + # prompt_tokens = token_count(prompt, model=current_model) + # logger.debug(f"Sende Prompt an OpenAI ({current_model}, geschätzt {prompt_tokens} Tokens)...") # Zu viel Lärm + + # Der OpenAI Call selbst kann Exceptions werfen (APIError, RateLimitError, InvalidRequestError etc.) + # Diese werden vom @retry_on_failure Decorator behandelt. + response = openai.ChatCompletion.create( + model=current_model, + messages=[{"role": "user", "content": prompt}], + temperature=temperature + ) + # Überprüfen Sie die Antwort auf Fehler (z.B. leere choices Liste) + if not response or not response.choices: + logger.error("OpenAI Call erfolgreich, aber keine Choices in der Antwort erhalten.") + # Werfen Sie eine spezifische Exception + raise openai.error.APIError("Keine Choices in OpenAI Antwort erhalten.") + + # Extrahieren Sie den Inhalt der ersten (und einzigen) Antwort + result = response.choices[0].message.content.strip() + + # Token zählen für die Antwort (optional) + # completion_tokens = token_count(result, model=current_model) + # total_tokens = response.usage.total_tokens # Usage info kann direkt aus response kommen + # logger.debug(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).") # Zu viel Lärm + + return result # Gibt den bereinigten Antwortstring zurück + + # Die spezifischen OpenAI Exceptions werden vom retry_on_failure gefangen. + # Nur andere unerwartete Exceptions kommen hier direkt an. + except Exception as e: + # Loggen Sie den unerwarteten Fehler + logger.error(f"Allgemeiner Fehler während OpenAI-Aufruf: {type(e).__name__} - {e}") + # Loggen Sie den Traceback für unerwartete Fehler + # logger.exception("Traceback des allgemeinen Fehlers:") # Der Retry-Decorator loggt das + + # Werfen Sie die Exception erneut, damit der retry_on_failure Decorator sie fangen kann. + # Dies ist wichtig, damit der Decorator den Fehler erkennt und Retries durchführt. + raise e + + +def summarize_website_content(raw_text): + """Erstellt Zusammenfassung von Website-Rohtext via OpenAI.""" + if not raw_text or str(raw_text).strip() == "" or str(raw_text).strip().lower() in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]: + logger.debug("summarize_website_content skipped: No valid raw text.") + return "k.A." + + # Kürze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden + # Die maximale Länge des Prompts ist das Limit minus der erwarteten Antwortlänge. + # Eine konservative Schätzung für den Text sind 3000 Zeichen. + max_raw_length = 3000 + if len(str(raw_text)) > max_raw_length: + logger.debug(f"Kürze Rohtext für Zusammenfassung von {len(str(raw_text))} auf {max_raw_length} Zeichen.") + raw_text = str(raw_text)[:max_raw_length] + + prompt = ( + "Du bist ein KI-Assistent, der Webinhalte analysiert.\n" + "Fasse den folgenden Text einer Unternehmenswebsite prägnant zusammen. " + "Konzentriere dich auf:\n" + "- Haupttätigkeitsfeld des Unternehmens\n" + "- Wichtigste Produkte und/oder Dienstleistungen\n" + "- Zielgruppe (falls erkennbar)\n\n" + f"Website-Text:\n```\n{raw_text}\n```\n\n" + "Zusammenfassung (max. 100 Wörter):" + ) + + # Call_openai_chat nutzt den retry_on_failure Decorator. + # Wenn call_openai_chat nach Retries eine Exception wirft, wird diese hier nicht gefangen, + # sondern weitergereicht (z.B. an _process_single_row), was gut ist. + # Wenn call_openai_chat erfolgreich ist, gibt es den String oder None zurück (obwohl es jetzt Exception bei Fehlern wirft). + try: + summary = call_openai_chat(prompt, temperature=0.2) + return summary if summary and summary.strip() else "k.A. (Keine Zusammenfassung erhalten)" + except Exception as e: + # Fehler beim OpenAI Call (wird vom retry_on_failure geloggt) + # Geben Sie einen Fehlerwert zurück + return f"k.A. (Fehler Zusammenfassung: {str(e)[:50]}...)" + + +# Übernommen aus summarize_batch_openai in Teil 7/9, angepasst als globale Funktion. +@retry_on_failure # Anwenden des Decorators auf die Batch-Funktion +def summarize_batch_openai(tasks_data): + """ + Fasst eine Liste von Rohtexten in einem einzigen OpenAI API Call zusammen. + Die Prüfung auf das Token-Limit wird jetzt primär der API überlassen. + + Args: + tasks_data (list): Eine Liste von Dictionaries, jedes enthält: + {'row_num': int, 'raw_text': str} + + Returns: + dict: Ein Dictionary, das Zeilennummern auf ihre Zusammenfassungen mappt. + z.B. {2122: "Zusammenfassung A", 2123: "Zusammenfassung B"} + Bei Fehlern oder fehlenden Zusammenfassungen wird ein Fehlerstring verwendet. + """ + if not tasks_data: return {} + + # Filtere Tasks, die gültigen Text haben. + # Achten Sie darauf, dass die Filterkriterien konsistent sind mit summarize_website_content. + valid_tasks = [t for t in tasks_data if t.get("raw_text") and str(t["raw_text"]).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]] + if not valid_tasks: + logger.debug("Keine gültigen Rohtexte für Batch-Zusammenfassung gefunden.") + # Geben Sie ein Ergebnisdict zurück, das dies für alle Zeilen widerspiegelt + return {t['row_num']: "k.A. (Kein gültiger Rohtext im Batch)" for t in tasks_data} + + logger.debug(f"Starte Batch-Zusammenfassung für {len(valid_tasks)} gültige Texte (Zeilen: {[t['row_num'] for t in valid_tasks]})...") + + # --- Aggregierten Prompt erstellen --- + prompt_parts = [ + "Du bist ein KI-Assistent, der Webinhalte analysiert.", + "Fasse für JEDEN der folgenden Texte einer Unternehmenswebsite prägnant zusammen. " + "Konzentriere dich dabei auf:\n" + "- Haupttätigkeitsfeld des Unternehmens\n" + "- Wichtigste Produkte und/oder Dienstleistungen\n" + "- Zielgruppe (falls erkennbar)\n\n" + "Gib das Ergebnis für JEDEN Text im folgenden Format aus, auf einer neuen Zeile:\n" + "RESULTAT : \n\n" + "Halte jede Zusammenfassung kurz, max. 100 Wörter.\n\n", + "--- Texte zur Zusammenfassung ---" + ] + text_block = "" + row_numbers_in_batch = [] # Zeilen, die tatsächlich im Prompt landen + + # Baue den Textblock zusammen. Kürze jeden einzelnen Text, um das Gesamtprompt-Limit nicht zu sprengen. + max_chars_per_single_text_in_batch = 1500 # Zeichenlimit für jeden Text innerhalb des Batch-Prompts + + for task in valid_tasks: + row_num = task['row_num'] + raw_text = str(task['raw_text']) # Sicherstellen, dass es ein String ist + raw_text_short = raw_text[:max_chars_per_single_text_in_batch] # Kürzen für den Prompt + + entry_text = f"\n--- TEXT Zeile {row_num} ---\n{raw_text_short}\n--- ENDE TEXT Zeile {row_num} ---\n" + text_block += entry_text + row_numbers_in_batch.append(row_num) # Füge die Zeilennummer hinzu + + if not row_numbers_in_batch: + # Sollte nur passieren, wenn valid_tasks leer war, was oben abgefangen wird + logger.error("Logikfehler: Keine Zeilen in row_numbers_in_batch trotz valid_tasks.") + return {t['row_num']: "FEHLER (Batch-Erstellung)" for t in tasks_data} + + + prompt_parts.append(text_block) + prompt_parts.append("\n--- Ende der Texte ---") + prompt_parts.append("\nBitte gib NUR die 'RESULTAT : ...' Zeilen zurück.") + final_prompt = "\n".join(prompt_parts) + + # Optional: Token zählen zur Info, aber nicht zur Blockade + # try: prompt_tokens = token_count(final_prompt); logger.debug(f"Geschätzte Prompt-Tokens für Batch: {prompt_tokens} (Limit ca. 4096 für gpt-3.5-turbo)"); + # except Exception as e_tc: logger.debug(f"Fehler beim Token-Zählen: {e_tc}"); + + + # --- OpenAI API Call (Die API wirft Fehler bei Token-Limit oder anderen Problemen) --- + # call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgültigem Fehler eine Exception + chat_response = None + try: + chat_response = call_openai_chat(final_prompt, temperature=0.2) + # Wenn call_openai_chat erfolgreich ist, gibt es den String zurück. + # Exceptions werden nach Retries geworfen. + + if not chat_response: + # Dieser Fall sollte nach der Änderung in call_openai_chat nicht mehr auftreten (würde Exception werfen) + logger.error("call_openai_chat gab unerwarteterweise None zurück für Batch-Zusammenfassung.") + # Behandeln Sie dies als Fehler für alle Zeilen im Batch + return {row_num: "FEHLER (OpenAI None Antwort)" for row_num in row_numbers_in_batch} + + + except Exception as e: + # Wenn call_openai_chat nach Retries eine Exception wirft + logger.error(f"Endgültiger FEHLER beim OpenAI-Batch-Aufruf für Zusammenfassung: {e}") + # Geben Sie ein Dictionary zurück, das signalisiert, dass für alle Zeilen im Batch ein Fehler aufgetreten ist + return {row_num: f"FEHLER API: {str(e)[:100]}" for row_num in row_numbers_in_batch} + + + # --- Antwort parsen --- + summaries = {} # Initialize with empty dict + lines = chat_response.strip().split('\n') + parsed_count = 0 + for line in lines: + # Matcht "RESULTAT :" und den Rest der Zeile + match = re.match(r"RESULTAT (\d+): (.*)", line.strip()) + if match: + row_num = int(match.group(1)) + summary_text = match.group(2).strip() + # Stellen Sie sicher, dass die Zeilennummer im ursprünglichen Batch war + if row_num in row_numbers_in_batch: + summaries[row_num] = summary_text + parsed_count += 1 + # else: logger.debug(f"Warnung: Antwort für unerwartete Zeilennummer {row_num} im Batch erhalten.") # Zu viel Lärm + + logger.debug(f"Batch-Zusammenfassung: {parsed_count} von {len(row_numbers_in_batch)} Zeilen erfolgreich geparst.") + + # Fügen Sie einen Fehlerwert für Zeilen hinzu, die nicht geparst werden konnten + if parsed_count < len(row_numbers_in_batch): + logger.warning(f"Nicht alle Zeilen aus dem Batch ({len(row_numbers_in_batch)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.") + logger.debug(f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}") + for row_num in row_numbers_in_batch: + if row_num not in summaries: + summaries[row_num] = "FEHLER: Antwort nicht geparst" + + + # Füge k.A. für Tasks hinzu, die ungültigen Rohtext hatten (aus valid_tasks gefiltert) + # Diese waren nie Teil des OpenAI Prompts + original_row_nums = {t['row_num'] for t in tasks_data} + for row_num in original_row_nums: + if row_num not in summaries: + summaries[row_num] = "k.A. (Ungültiger Rohtext im Batch)" + + + return summaries # Rückgabe des Dictionarys mit Ergebnissen oder Fehlern + + +# Übernommen aus evaluate_branche_chatgpt in Teil 4/7, angepasst als globale Funktion. +# Nutzt globale ALLOWED_TARGET_BRANCHES und TARGET_SCHEMA_STRING. +@retry_on_failure # Anwenden des Decorators auf die Funktion, die call_openai_chat aufruft +def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary): + """ + Ordnet das Unternehmen basierend auf den angegebenen Informationen exakt einer Branche + aus dem Ziel-Branchenschema (nur Kurzformen) zu. Validiert den ChatGPT-Vorschlag + strikt gegen die erlaubten Kurzformen und führt einen Fallback auf die (extrahierte) + CRM-Kurzform durch, falls der Vorschlag ungültig ist. + + Args: + crm_branche (str): Branche laut CRM (kann noch Präfix enthalten). + beschreibung (str): Unternehmensbeschreibung (CRM). + wiki_branche (str): Branche aus Wikipedia (falls vorhanden). + wiki_kategorien (str): Wikipedia-Kategorien. + website_summary (str): Zusammenfassung des Website-Inhalts. + + Returns: + dict: Enthält "branch" (die finale, gültige Kurzform oder Fehler), + "consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid', 'error_...'), + "justification" (Begründung von ChatGPT oder Fallback-Info). + Wirft Exception bei API-Fehlern. + """ + global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING + + # Grundlegende Prüfung: Ist das Schema überhaupt geladen? + if not ALLOWED_TARGET_BRANCHES: + logger.critical("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Kann Branchen nicht validieren.") + # Geben Sie ein Fehlerergebnis zurück + return {"branch": "FEHLER - SCHEMA FEHLT", "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"} + + # Erstelle Lookup für erlaubte Branches (case-insensitive) + allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES} + + # --- Prompt für ChatGPT erstellen --- + # Beginne mit den Regeln und der Liste der gültigen Kurzformen + prompt_parts = [TARGET_SCHEMA_STRING] # Enthält bereits die Liste und Anweisungen + prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:") + + # Füge nur vorhandene Informationen hinzu und kürze sie ggf. + # Stellen Sie sicher, dass die Werte keine None-Typen sind + if crm_branche and str(crm_branche).strip() and str(crm_branche).strip() != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {str(crm_branche).strip()}") + if beschreibung and str(beschreibung).strip() and str(beschreibung).strip() != "k.A.": prompt_parts.append(f"- Beschreibung: {str(beschreibung).strip()[:500]}...") # Kürzen + if wiki_branche and str(wiki_branche).strip() and str(wiki_branche).strip() != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {str(wiki_branche).strip()[:300]}...") # Kürzen + if wiki_kategorien and str(wiki_kategorien).strip() and str(wiki_kategorien).strip() != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {str(wiki_kategorien).strip()[:500]}...") # Kürzen + if website_summary and str(website_summary).strip() and str(website_summary).strip() != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {str(website_summary).strip()[:500]}...") # Kürzen + + + # Fallback, wenn zu wenige Infos da sind (mindestens 2 relevante Zeilen im Prompt neben dem Schema) + # Der Prompt hat immer mindestens 1 Zeile (Schema) + 1 Zeile (Instruktion "Ordne zu..."). + # Prüfen wir, ob mindestens 2 Info-Zeilen hinzugefügt wurden. + if len(prompt_parts) < 3: # 1 (Schema) + 1 (Instruktion) + <2 (Infos) + logger.warning("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen (<2 Quellen) für Branchenevaluierung.") + # Geben Sie ein Fehlerergebnis zurück, verwenden Sie die CRM-Branche als Fallback + return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"} + + # Prompt für das Antwortformat ist bereits in TARGET_SCHEMA_STRING enthalten. + + prompt = "\n".join(prompt_parts) + # logger.debug(f"Erstellter Prompt für Branchenevaluierung:\n---\n{prompt}\n---") # Zu viel Lärm + + # --- ChatGPT aufrufen --- + # call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgültigem Fehler eine Exception + chat_response = None + try: + chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung + + if not chat_response: + # Dieser Fall sollte nach der Änderung in call_openai_chat nicht mehr auftreten (würde Exception werfen) + logger.error("call_openai_chat gab unerwarteterweise None zurück für Branchenevaluation.") + raise openai.error.APIError("Keine Antwort von OpenAI erhalten für Branchenevaluation.") # Wirf eine Exception + + except Exception as e: + # Wenn call_openai_chat nach Retries eine Exception wirft + logger.error(f"Endgültiger FEHLER beim OpenAI-Aufruf für Branchenevaluation: {e}") + # Geben Sie ein Fehlerergebnis zurück, verwenden Sie die CRM-Branche als Fallback + # Hängen Sie die Fehlermeldung an die Begründung an. + return {"branch": crm_branche, "consistency": "error_api_failed", "justification": f"Fehler API: {str(e)[:100]}"} + + + # --- Antwort parsen --- + lines = chat_response.strip().split("\n") + # Initialisiere Ergebnisdict mit Fallback-Werten oder leeren Strings + result = {"branch": None, "consistency": None, "justification": ""} + suggested_branch = "" + parsed_branch = False + for line in lines: + line_lower = line.lower() + if line_lower.startswith("branche:"): + # Extrahiere die vorgeschlagene Branche, bereinige Leerzeichen und Anführungszeichen + suggested_branch = line.split(":", 1)[1].strip().strip('"\'') + parsed_branch = True + elif line_lower.startswith("übereinstimmung:"): + # Wir überschreiben die Konsistenz später basierend auf unserer Logik, ignorieren Sie die KI-Antwort hier + pass + elif line_lower.startswith("begründung:"): + # Erfasse die Begründung. Wenn es mehrere Begründungszeilen gibt, hänge sie an. + if result["justification"]: result["justification"] += " " + line.split(":", 1)[1].strip() + else: result["justification"] = line.split(":", 1)[1].strip() + # Behandle andere mögliche unerwartete Zeilen (optional) + # elif line_lower.startswith(("resultat", "eintrag", "antwort")): + # logger.warning(f"Unerwartete Zeile im Branchen-Prompt gefunden: {line[:100]}...") + + + if not parsed_branch or not suggested_branch: # Prüfe, ob Branch geparst wurde UND nicht leer ist + logger.error(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht oder nur leer aus Antwort parsen: {chat_response[:500]}...") # Logge Anfang der Antwort + # Geben Sie ein Fehlerergebnis zurück, verwenden Sie die CRM-Branche als Fallback + return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler Parsing: Antwortformat unerwartet."} + + + # --- Validierung des ChatGPT-Vorschlags --- + final_branch = None + suggested_branch_lower = suggested_branch.lower() + + # 1. Ist der vorgeschlagene Branch EXAKT im Ziel-Schema enthalten? + if suggested_branch_lower in allowed_branches_lookup: + final_branch = allowed_branches_lookup[suggested_branch_lower] # Nimm die korrekte Schreibweise aus der Liste + logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').") + result["consistency"] = "pending_comparison" # Temporärer Status vor Vergleich mit CRM + + else: + # --- Fallback-Logik, wenn Vorschlag ungültig ist --- + logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge). Starte Fallback...") + + # Versuche Kurzform aus CRM-Branche zu extrahieren + crm_short_branch = "k.A." # Default + if crm_branche and ">" in str(crm_branche): + crm_short_branch = str(crm_branche).split(">", 1)[1].strip() + elif crm_branche and str(crm_branche).strip() and str(crm_branche).strip() != "k.A.": # Wenn CRM schon Kurzform sein könnte + crm_short_branch = str(crm_branche).strip() + + logger.debug(f" Fallback: Prüfe extrahierte CRM-Kurzform: '{crm_short_branch}'") + crm_short_branch_lower = crm_short_branch.lower() + + # 2. Ist die extrahierte CRM-Kurzform EXAKT im Ziel-Schema enthalten? + if crm_short_branch != "k.A." and crm_short_branch_lower in allowed_branches_lookup: + final_branch = allowed_branches_lookup[crm_short_branch_lower] # Nimm korrekte Schreibweise + result["consistency"] = "fallback_crm_valid" # Setze Fallback-Status + # Kombiniere ChatGPT Begründung (falls vorhanden) mit Fallback-Info + fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet." + result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})" + logger.info(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'") + else: + # 3. Wenn auch CRM-Kurzform ungültig + final_branch = suggested_branch # Behalte ungültigen Vorschlag + result["consistency"] = "fallback_invalid" # Setze Fehler-Fallback-Status + error_reason = f"Fehler: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar." + result["justification"] = f"{error_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})" + logger.warning(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'") + # Alternativ: Setze final_branch auf einen expliziten Fehlerwert, um es im Sheet hervorzuheben + # final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG" + + + # Setze den finalen Branch im Ergebnis-Dictionary + # Verwenden Sie einen Standard-Fehlerwert, falls final_branch aus irgendeinem Grund immer noch None ist + result["branch"] = final_branch if final_branch else "FEHLER" + + # --- Konsistenzprüfung (Finale Bewertung des final_branch vs. CRM-Kurzform) --- + # Extrahiere CRM-Kurzform für den Vergleich (erneut oder Variable von oben) + crm_short_to_compare = "k.A." + if crm_branche and ">" in str(crm_branche): + crm_short_to_compare = str(crm_branche).split(">", 1)[1].strip() + elif crm_branche and str(crm_branche).strip() and str(crm_branche).strip() != "k.A.": + crm_short_to_compare = str(crm_branche).strip() + + + # Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform (case-insensitive) + # Aktualisiere den Consistency-Status, WENN er noch 'pending_comparison' ist. + # Fallback-Status ('fallback_crm_valid', 'fallback_invalid') sollen erhalten bleiben. + if result["consistency"] == "pending_comparison" and result["branch"] != "FEHLER": + if result["branch"].lower() == crm_short_to_compare.lower(): + result["consistency"] = "ok" # Übereinstimmung mit CRM + else: + result["consistency"] = "X" # Keine Übereinstimmung mit CRM + + + # Entferne den temporären Status, falls er noch da ist (sollte nicht passieren) + if result["consistency"] == "pending_comparison": + logger.warning("Konsistenzprüfung blieb im Status 'pending_comparison', setze auf 'error_comparison_failed'.") + result["consistency"] = "error_comparison_failed" + elif result["consistency"] is None: # Sollte nicht passieren + logger.error("Konsistenz blieb unerwartet None, setze auf 'error_unknown_state'.") + result["consistency"] = "error_unknown_state" + + + # Debug-Ausgabe des finalen Ergebnisses vor Rückgabe + logger.debug(f"Finale Branch-Evaluation Ergebnis: Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:100]}...'") + + return result # Rückgabe des Ergebnis-Dictionarys + + +# --- SERP API / LINKEDIN FUNCTIONS --- +# Übernommen aus Ihrem Code (Teil 10), angepasst als globale Funktionen. + +# serp_wikipedia_lookup ist bereits in Teil 1/18 enthalten (oder sollte es sein, da es direkt nach retry_on_failure kam) + + +@retry_on_failure +def serp_website_lookup(company_name): + """ + Ermittelt die offizielle Website eines Unternehmens über SerpAPI (Google Suche). + Gibt die normalisierte URL zurück oder "k.A.". + """ + serp_key = Config.API_KEYS.get('serpapi') + if not serp_key: + logger.error("Fehler: SerpAPI Key nicht verfügbar für Website Lookup.") + # Werfen Sie eine Exception, damit retry_on_failure dies behandeln kann + raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.") + + if not company_name or str(company_name).strip() == "": + logger.warning("serp_website_lookup: Kein Firmenname angegeben.") + # Werfen Sie einen ValueError + raise ValueError("Kein Firmenname für SerpAPI Website Lookup angegeben.") + + # Blacklist unerwünschter Domains (kann in Config verschoben werden) + blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com", "xing.com", "youtube.com", "facebook.com", "twitter.com", "instagram.com"] + + query = f'{company_name} offizielle Website' # Präzisere Query + params = { + "engine": "google", + "q": query, + "api_key": serp_key, + "hl": "de", # Host Language (Sprache der Benutzeroberfläche) + "gl": "de", # Geo Location (Land) + "safe": "active" # SafeSearch aktivieren + } + api_url = "https://serpapi.com/search" + + try: + # Der Requests Call wird vom retry_on_failure Decorator behandelt + response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Konfigurierbarer Timeout + response.raise_for_status() # Wirft HTTPError für schlechte Antworten + data = response.json() + + # 1. Knowledge Graph prüfen (oft die offizielle Seite) + if "knowledge_graph" in data and "website" in data["knowledge_graph"]: + kg_url = data["knowledge_graph"].get("website") + if kg_url: + # Prüfen Blacklist VOR Normalisierung + if any(bad_domain in kg_url.lower() for bad_domain in blacklist): + logger.debug(f" -> SerpAPI Website Lookup: KG URL '{kg_url}' auf Blacklist. Übersprungen.") + else: + normalized_url = simple_normalize_url(kg_url) # Nutzt globale Funktion + if normalized_url != "k.A.": + logger.info(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph für '{company_name}' gefunden.") + return normalized_url # Erfolgreich gefunden und zurückgegeben + + + # 2. Organische Ergebnisse prüfen + if "organic_results" in data: + # Iteriere durch die ersten Ergebnisse + for result in data["organic_results"][:5]: # Prüfe nur die Top 5 organischen Ergebnisse + url = result.get("link", "") + title = result.get("title", "") # Titel kann Kontext geben + snippet = result.get("snippet", "") # Snippet kann Kontext geben + + # Filtere: Muss gültige URL sein, darf nicht auf Blacklist sein, muss http/https starten + if url and url.lower().startswith(("http://", "https://")) and not any(bad_domain in url.lower() for bad_domain in blacklist): + + normalized_url = simple_normalize_url(url) # Nutzt globale Funktion + + if normalized_url != "k.A.": + # Zusätzliche Plausibilitätsprüfung: Enthält die Domain Teile des Firmennamens? + # Oder ist der Firmenname im Titel/Snippet? + # normalize_company_name nutzt globale Funktion + normalized_company = normalize_company_name(company_name) + domain_part_normalized = normalized_url.replace('www.', '').split('.')[0] # Erster Teil der Domain + title_lower = title.lower() + snippet_lower = snippet.lower() + + # Prüfe, ob der normalisierte Domain-Teil im normalisierten Firmennamen enthalten ist + domain_name_match = domain_part_normalized in normalized_company + # Prüfe, ob der normalisierte Firmenname im Titel oder Snippet vorkommt + name_in_result_text = normalized_company in title_lower or normalized_company in snippet_lower + + # Definieren Sie Kriterien für einen guten Treffer im organischen Ergebnis + if domain_name_match or name_in_result_text: + logger.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden (Domain/Name Match).") + return normalized_url # Erfolgreich gefunden und zurückgegeben + else: + # Loggen Sie, warum die URL übersprungen wurde (nur auf Debug) + logger.debug(f" -> SerpAPI Website Lookup: URL '{normalized_url}' übersprungen (Domain/Name Match fehlgeschlagen). Domain='{domain_part_normalized}', Name='{normalized_company}'.") + # Fahren Sie fort, um den nächsten organischen Treffer zu prüfen + + + # Wenn die Schleife durchläuft und keine passende URL gefunden wurde + logger.info(f"SERP Lookup: Keine passende Website für '{company_name}' gefunden nach Prüfung KG und Top Organic Results.") + return "k.A." # Signalisiert, dass keine passende URL gefunden wurde + + except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut + # Loggen Sie den Fehler (wird vom retry_on_failure geloggt) + logger.error(f"FEHLER bei der SerpAPI Website Suche für '{company_name}': {e}") + # Geben Sie einen Fehlerwert zurück oder "k.A." + return "k.A. (Fehler Suche)" # Signalisiert Fehler bei der Suche + + +@retry_on_failure +def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10): + """ + Sucht LinkedIn Kontakte für ein Unternehmen und eine Position via SerpAPI (Google). + Gibt eine Liste von Kontakt-Dictionaries zurück. + """ + serp_key = Config.API_KEYS.get('serpapi') + if not serp_key: + logger.error("Fehler: SerpAPI Key nicht verfügbar für LinkedIn Suche.") + raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.") + + if not all([company_name, position_query, crm_kurzform]) or not all(isinstance(x, str) for x in [company_name, position_query, crm_kurzform]): + logger.warning(f"search_linkedin_contacts: Fehlende oder ungültige Eingabedaten (Name, Position, Kurzform).") + raise ValueError("Fehlende oder ungültige Eingabedaten für LinkedIn Suche.") + + # Query anpassen für bessere Ergebnisse + # Suche nach "[Position]" UND "[Firmenkurzform]" auf der LinkedIn /in/ Seite + # crm_kurzform ist oft im Titel oder der Beschreibung + query = f'site:linkedin.com/in/ "{position_query}" "{crm_kurzform}"' + # Optional: Fügen Sie den vollen Firmennamen hinzu, kann aber die Ergebnisse einschränken + # query = f'site:linkedin.com/in/ "{position_query}" "{crm_kurzform}" "{company_name}"' + + params = { + "engine": "google", + "q": query, + "api_key": serp_key, + "hl": "de", # Host Language + "gl": "de", # Geo Location + "num": num_results # Anzahl der Ergebnisse pro SerpAPI Call + } + api_url = "https://serpapi.com/search" + + found_contacts = [] # Liste zur Sammlung der gefundenen Kontakte + + try: + # Der Requests Call wird vom retry_on_failure Decorator behandelt + response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Konfigurierbarer Timeout + response.raise_for_status() # Wirft HTTPError für schlechte Antworten + data = response.json() + + if "organic_results" in data: + # Gehe durch die organischen Suchergebnisse + for result in data["organic_results"]: + title = result.get("title", "") + linkedin_url = result.get("link", "") + snippet = result.get("snippet", "") # Snippet kann Position oder Firma enthalten + + # Filtere: Muss eine LinkedIn Profil-URL sein und die Kurzform muss im Titel vorkommen + # oder eine hohe Namensähnlichkeit aufweisen + if not linkedin_url or "linkedin.com/in/" not in linkedin_url or "/sales/" in linkedin_url: + #logger.debug(f" -> LinkedIn Treffer übersprungen (kein Profil-URL): {linkedin_url}") # Zu viel Lärm + continue + + # Prüfe, ob die Firmenkurzform im Titel oder Snippet vorkommt + # Oder ob der Titel eine hohe Ähnlichkeit mit "[Name] - [Position] bei [Kurzform]" hat + title_lower = title.lower() + snippet_lower = snippet.lower() + crm_kurzform_lower = crm_kurzform.lower() + position_query_lower = position_query.lower() + + kurzform_in_text = crm_kurzform_lower in title_lower or crm_kurzform_lower in snippet_lower + + # Vereinfachte Namens-/Positionsextraktion aus dem Titel + name_part = "" + pos_part = position_query # Fallback + + # Versuche gängige Trennzeichen im Titel (z.B. Name - Position | Firma) + separators = ["–", "-", "|", " at ", " bei "] + title_cleaned = title.replace("...", "").strip() + + found_sep = False + for sep in separators: + if sep in title_cleaned: + parts = title_cleaned.split(sep, 1) + name_part = parts[0].strip() + # Versuche, LinkedIn/Profil etc. aus Namen zu entfernen + name_part = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', name_part, flags=re.IGNORECASE).strip() + name_part = re.sub(r'[\s|\-]*LinkedIn$', '', name_part, flags=re.IGNORECASE).strip() + + + # Positionsteil ist alles nach dem ersten Trenner + potential_pos_company = parts[1].strip() + # Versuche, Firmennamen-Teile (Kurzform) und LinkedIn-Suffixe zu entfernen + pos_company_cleaned = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', potential_pos_company, flags=re.IGNORECASE).strip() + pos_company_cleaned = re.sub(r'[\s|\-]*LinkedIn$', '', pos_company_cleaned, flags=re.IGNORECASE).strip() + + # Entferne die Firmenkurzform, wenn sie im Positionsteil vorkommt + if crm_kurzform_lower in pos_company_cleaned.lower(): + # Ersetze nur die erste gefundene Instanz der Kurzform (ganzes Wort) + pos_company_cleaned = re.sub(r'\b' + re.escape(crm_kurzform_lower) + r'\b', '', pos_company_cleaned, flags=re.IGNORECASE).strip() + pos_company_cleaned = re.sub(r'\s+', ' ', pos_company_cleaned).strip() # Leerzeichen reduzieren nach Entfernung + + pos_part = pos_company_cleaned if pos_company_cleaned else position_query + found_sep = True + break + + if not found_sep: # Kein Trennzeichen gefunden, versuche andere Muster + # Muster: "[Name] [Position_Query] - LinkedIn" + if position_query_lower in title_lower: + # Split am Position_Query, nimm den Teil davor als Namen + name_before_pos = title_lower.split(position_query_lower, 1)[0].strip() + name_part = title_cleaned[:len(name_before_pos)].strip() # Nimm Originaltext bis zur Position + + # Teile Namen in Vor- und Nachname (einfache Annahme) + firstname = "" + lastname = "" + name_parts = name_part.split() + if len(name_parts) > 1: + firstname = name_parts[0] + lastname = " ".join(name_parts[1:]) + elif len(name_parts) == 1: + firstname = name_parts[0] # Nur Vorname gefunden? + + if not firstname or not name_part: # Wenn Name nicht extrahiert werden konnte, überspringe + # self.logger.debug(f"LinkedIn Treffer übersprungen: Name konnte nicht extrahiert werden aus Titel '{title}'") # Zu viel Lärm + continue + + # Zusätzliche Plausibilitätsprüfung: Position Query muss im Titel oder Snippet vorkommen ODER Kurzform muss im Titel/Snippet sein + position_in_text = position_query_lower in title_lower or position_query_lower in snippet_lower + + # Akzeptiere den Kontakt, wenn (Position oder Kurzform in Text) UND Name extrahiert wurde + if position_in_text or kurzform_in_text: + contact_data = { + "Firmenname": company_name, # Originalname für Kontext + "CRM Kurzform": crm_kurzform, + "Website": website, # Website der Firma + "Vorname": firstname, + "Nachname": lastname, + "Position": pos_part, # Extrahierte oder Fallback Position + "LinkedInURL": linkedin_url + } + found_contacts.append(contact_data) + # self.logger.debug(f"Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part} (URL: {linkedin_url})") # Zu viel Lärm + # else: self.logger.debug(f"LinkedIn Treffer übersprungen (kein Position/Kurzform Match in Text): '{title}'") # Zu viel Lärm + + + logger.info(f"LinkedIn Suche für '{position_query}' bei '{crm_kurzform}' ergab {len(found_contacts)} Kontakte.") + return found_contacts # Gibt die Liste der gefundenen Kontakte zurück + + except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut + # Loggen Sie den Fehler (wird vom retry_on_failure geloggt) + logger.error(f"FEHLER bei der SerpAPI LinkedIn Suche (Query: '{position_query}', Firma: '{crm_kurzform}'): {e}") + # Geben Sie eine leere Liste zurück, da keine Kontakte gefunden wurden + return [] # Signalisiert Fehler bei der Suche + + +# --- Experimentelle Website Details Scraping Funktion --- +# Diese Funktion wurde in DataProcessor.process_website_details aufgerufen +# Ihre Implementierung hängt stark von der Struktur der Zielwebsites ab. +# HIER ist ein Platzhalter für diese Funktion. Sie MUSS implementiert werden. +def scrape_website_details(url): + """ + EXPERIMENTELL: Scrapt eine Website und extrahiert spezifische Details. + Diese Funktion muss je nach Zielwebsite(s) implementiert/angepasst werden. + + Args: + url (str): Die URL der Website. + + Returns: + str: Extrahierte Details als String oder Fehler/k.A. + """ + logger.warning(f"Platzhalter Funktion 'scrape_website_details' für URL {url} aufgerufen.") + # Beispiel: Einfaches Abrufen des Tags + try: + # Nutzt get_page_soup aus der WikipediaScraper Klasse, aber das ist eine Methode. + # Wir brauchen hier eine separate Funktion oder eine Instanz. + # Oder wir verschieben diese Logik in die DataProcessor Klasse, die den Scraper hat. + # Am besten: Lassen Sie diese Funktion global, aber nutzen Sie requests direkt oder eine Hilfsfunktion. + # Nutzen wir requests direkt mit retry_on_failure + @retry_on_failure + def get_soup_for_details(target_url): + response = requests.get(target_url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) + response.raise_for_status() + response.encoding = response.apparent_encoding + return BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser')) + + soup = get_soup_for_details(url) + + if soup: + title = soup.find('title') + meta_desc = soup.find('meta', attrs={'name': 'description'}) + h1 = soup.find('h1') + + details_list = [] + if title: details_list.append(f"Title: {clean_text(title.get_text())}") + if meta_desc and meta_desc.get('content'): details_list.append(f"Description: {clean_text(meta_desc['content'])}") + if h1: details_list.append(f"H1: {clean_text(h1.get_text())}") + + if details_list: + return " | ".join(details_list) + else: + return "k.A. (Keine Standard-Details gefunden)" + + else: + return "k.A. (Scraping fehlgeschlagen)" # Fehler wurde bereits geloggt + + except Exception as e: # retry_on_failure wirft am Ende Exception + logger.error(f"FEHLER in scrape_website_details für {url}: {e}") + return f"FEHLER: {str(e)[:100]}" # Rückgabe der Fehlermeldung + + +# TODO: Weitere globale Helferfunktionen (z.B. für FSM, Emp, Umsatz Schätzung Prompts und Parsing) müssen hier implementiert werden, +# falls sie nicht in den DataProcessor integriert wurden. Platzhalter wurden in DataProcessor._process_single_row hinzugefügt. # ============================================================================== # 4. GOOGLE SHEET HANDLER CLASS